Compare commits

..

42 Commits

Author SHA1 Message Date
xiaoxia e45a8fe775 feat: #1798 AI数字人前端页面 — 5列水平面板布局 (#1803)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Integration Tests (push) Successful in 1m19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m9s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 1m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m50s
CI/CD Pipeline / Validate - Security (push) Successful in 5m8s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Unit Tests (push) Successful in 7m46s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-08 18:31:02 +08:00
xiaoxia 800f90d8c6 feat: #1798 AI数字人渲染合成管线 (#1802)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m15s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Style (push) Successful in 2m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m49s
CI/CD Pipeline / Validate - Security (push) Successful in 8m27s
CI/CD Pipeline / Unit Tests (push) Successful in 8m34s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (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 / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 18:03:06 +08:00
xiaoxia f7825e3956 feat: #1796 MediaKit 对口型后端对接 (#1801)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 17s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m26s
CI/CD Pipeline / Validate - Style (push) Successful in 2m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m17s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m47s
CI/CD Pipeline / Unit Tests (push) Successful in 8m7s
CI/CD Pipeline / Validate - Security (push) Successful in 9m43s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 16:47:57 +08:00
xiaoxia 2cca03f680 Merge pull request 'feat: #1795 文案库 CRUD(Script 模型 + Service + API + 迁移 + 35 测试)' (#1799) from feat/scripts-crud-1795 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 6s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m3s
CI/CD Pipeline / Integration Tests (push) Successful in 2m14s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m40s
CI/CD Pipeline / Validate - Style (push) Successful in 2m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m4s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m6s
CI/CD Pipeline / Unit Tests (push) Successful in 8m52s
CI/CD Pipeline / Validate - Security (push) Successful in 10m38s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-09-08 15:42:54 +08:00
saas-backend-agent 277ff5428e fix: 修 test_rollback_value_error_400 pre-existing failure
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m20s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
AI Code Review / AI Code Review (pull_request) Successful in 6m43s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Failing after 11m6s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m17s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m47s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 11s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m18s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m52s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 10m1s
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
endpoint 对 ValueError 返回 404(版本不存在=Not Found),
测试断言写的 400 是错的,改为 404 与 endpoint 行为一致。
2026-09-08 15:28:29 +08:00
xiaoxia a4c008e829 fix: #1799 批量生成进度卡片 grid 列宽过窄导致标题和进度条重叠
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m31s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m15s
CI/CD Pipeline / Validate - Style (push) Successful in 2m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m36s
CI/CD Pipeline / Unit Tests (push) Failing after 6m25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m27s
CI/CD Pipeline / Validate - Security (push) Successful in 9m9s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-08 15:27:21 +08:00
saas-backend-agent 24b28bfc89 feat: #1795 文案库 CRUD(Script 模型 + Service + API + 迁移 + 35 测试)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m59s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m16s
AI Code Review / AI Code Review (pull_request) Successful in 6m23s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m28s
- 新建 ScriptModel (packages/adapters/sqlalchemy_impl/models.py)
  字段: id, user_id(indexed), title, content, segments(JSON), tags(JSON), created_at, updated_at
- 新建 script_service.py: CRUD 封装,用户隔离,tag 筛选,分页
- 新建 schemas/script.py: Pydantic request/response schemas
- 新建 routes/scripts.py: RESTful API (GET/POST/PUT/DELETE /api/v1/scripts)
- 新建 alembic 070_add_scripts_table.py: scripts 表 + user_id 索引
- 注册路由到 router.py (prefix=/scripts, tag=ScriptLibrary)
- 35 单元测试: service 15 + schema/route 20
2026-09-08 15:19:57 +08:00
xiaoxia 6904fce511 fix: #1789 标题字号滑块拖动回弹 — useMemo 稳定引用 + ref 防御 (#1793)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m20s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m42s
CI/CD Pipeline / Validate - Style (push) Successful in 2m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m27s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m30s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m10s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Validate - Security (push) Successful in 6m17s
CI/CD Pipeline / Unit Tests (push) Failing after 8m32s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-08 13:28:50 +08:00
xiaoxia 9dec75c365 feat: #1789 标题 drawtext 滤镜渲染 — 单视频+批量生成都生效 (#1792)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m12s
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m45s
CI/CD Pipeline / Validate - Style (push) Successful in 2m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m14s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m29s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m41s
CI/CD Pipeline / Validate - Security (push) Successful in 6m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m30s
CI/CD Pipeline / Unit Tests (push) Failing after 7m32s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 13:14:13 +08:00
xiaoxia 7997f083a5 fix: #1789 标题设置 UI 控件 + #1790 空模板时间线不显示刻度 (#1791)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 52s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m6s
CI/CD Pipeline / Validate - Style (push) Successful in 2m18s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m32s
CI/CD Pipeline / Unit Tests (push) Failing after 7m51s
CI/CD Pipeline / Validate - Security (push) Successful in 9m14s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 11:53:47 +08:00
xiaoxia cf195685b9 Merge pull request 'feat: #1768 节奏曲线模板多样化 — 8种预设 + 时长钳制 + 误差校验' (#1788) from feat/rhythm-templates-diversify-1768 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 26s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 22s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 55s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 45s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m35s
CI/CD Pipeline / Validate - Style (push) Successful in 2m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
AI Code Review / AI Code Review (pull_request) Successful in 7m19s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 13s
CI/CD Pipeline / Unit Tests (push) Failing after 8m30s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 53s
CI/CD Pipeline / Validate - Security (push) Successful in 8m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-08 11:04:03 +08:00
xiaoxia ee4636e087 feat: #1768 节奏曲线模板多样化 — 8种预设 + 时长钳制 + 误差校验
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
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 / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m27s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m48s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
AI Code Review / AI Code Review (pull_request) Successful in 6m18s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m17s
CI/CD Pipeline / Build Production 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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m13s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 34s
变更:
- RHYTHM_TEMPLATES 从 6 种扩展到 8 种(新增 [2,2,1,1,2] 和 [3,2,1,2,1])
- 修复 MIN_CLIP_DURATION 被重复定义为 1.0 的 bug(恢复为 2.0)
- plan_clip_durations 新增 asset_durations 参数,钳制最大片段时长 <= 素材可用时长 × 90%
- 新增时长总和误差校验(成片净时长与配音时长误差 <= 0.5s),超限时末段补偿修正
- edit_plan_service.apply_voice_duration_to_plan 传入素材时长参与钳制
- 48 个新增单元测试全部通过

向后兼容:asset_durations 默认 None,不影响现有调用方
2026-09-08 10:57:16 +08:00
xiaoxia 5678779232 feat: #1776 素材库 asset_count 自动同步维护 (#1787)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
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 / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 12s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m58s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m2s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m9s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 13s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
AI Code Review / AI Code Review (pull_request) Successful in 6m29s
CI/CD Pipeline / Validate - Security (push) Successful in 6m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m40s
CI/CD Pipeline / Unit Tests (push) Failing after 10m13s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 10:30:21 +08:00
xiaoxia 44224cfaf6 feat: 转场位置与类型随机化 (#1766) (#1786)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 7s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
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 / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 5m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m33s
CI/CD Pipeline / Unit Tests (push) Failing after 9m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 10:17:35 +08:00
xiaoxia 98cf571ab3 feat: #1767 BGM 池差异化分配,打破变体间音频指纹一致性 (#1785)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m49s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Validate - Style (push) Successful in 2m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m20s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 10:12:52 +08:00
xiaoxia 12a9efc65d fix(test): 移除 InMemory 并发测试改为顺序幂等验证 (#1784)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) 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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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 / PR Build Worker Image (pull_request) Successful in 35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 49s
CI/CD Pipeline / Staging E2E Tests (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 / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m55s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 48s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m7s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m16s
CI/CD Pipeline / Validate - Style (push) Successful in 2m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 4m46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m31s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m12s
AI Code Review / AI Code Review (pull_request) Successful in 6m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Unit Tests (push) Failing after 7m59s
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 / CI Gate (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 / Canary Release to Production (push) Has been skipped
Squash merge PR #1784: 修复 #1775 并发测试在 CI 失败(InMemory 无 DB 约束)
2026-09-08 09:11:05 +08:00
xiaoxia 9c0474ef9c feat: #1775 默认项目/素材库幂等化(DB 唯一约束兜底)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (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 Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 42s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m53s
CI/CD Pipeline / Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m43s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 3m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 5m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m13s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
CI/CD Pipeline / Unit Tests (push) Failing after 7m58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Squash merge PR #1783 into develop
2026-09-08 08:43:31 +08:00
xiaoxia 691c811cd4 chore: 删除无引用的旧7步流程遗留组件 GenerationStatus (#1782)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (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 Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 20s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 20s
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 / 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 31s
CI/CD Pipeline / CI Gate (pull_request) Successful in 20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Validate - Security (push) Successful in 5m57s
AI Code Review / AI Code Review (pull_request) Successful in 6m25s
CI/CD Pipeline / Unit Tests (push) Failing after 8m20s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:46:49 +08:00
xiaoxia 2114b7e7ae fix: Step5 播放器上移+去完成提示;Step6 封面页去视频全宽居中 (#1781)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 14s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 14s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 22s
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 45s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 48s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m19s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m40s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m32s
CI/CD Pipeline / Integration Tests (push) Successful in 2m43s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Style (push) Successful in 2m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m0s
CI/CD Pipeline / Validate - Security (push) Successful in 6m12s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m4s
CI/CD Pipeline / Unit Tests (push) Failing after 8m36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 10m56s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 12m44s
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:31:06 +08:00
xiaoxia 7766ba1479 fix(#1774): 模板不存在明确返回404并收敛双表异常降级逻辑 (#1779)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (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 Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:30:08 +08:00
xiaoxia ef686dde8f fix(#1769): 模板列表过滤无片段配置的无效模板,避免前端选中后400 (#1772)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / PR Build Worker Image (push) 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 / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 47s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 58s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m9s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m10s
CI/CD Pipeline / Validate - Style (push) Successful in 2m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m24s
CI/CD Pipeline / Validate - Security (push) Successful in 7m3s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m39s
CI/CD Pipeline / Unit Tests (push) Successful in 9m55s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:10:13 +08:00
xiaoxia 01026156ae fix(#1777): Step2素材库只显示视频库 + 失效模板运行时自动回退 (#1780)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 22s
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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 38s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production 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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
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 / Validate - Python (mypy + alembic) (push) Successful in 2m13s
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 API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m30s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 00:07:18 +08:00
xiaoxia 42c0885813 Merge pull request 'fix: #1765 generate_pixel_perturbation 支持 int seed 可复现' (#1778) from fix/pixel-perturbation-seed into develop
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m5s
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 / CI Gate (pull_request) Successful in 3s
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 / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m55s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m38s
CI/CD Pipeline / Validate - Style (push) Successful in 2m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m16s
CI/CD Pipeline / Unit Tests (push) Successful in 8m6s
CI/CD Pipeline / Validate - Security (push) Successful in 16m13s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-07 23:01:36 +08:00
saas-backend-agent cd6d8615e6 test: #1765 补充 int seed 分支测试,diff coverage 达标
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web 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 Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m30s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m34s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 9s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m37s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 20m16s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
新增 5 个测试覆盖 int seed 入参分支(reproducible/与 Random 对象
等价/None 默认),满足 diff coverage ≥60% 要求。
2026-09-07 22:47:22 +08:00
saas-backend-agent fcc7863b31 fix: #1765 generate_pixel_perturbation 支持 int seed 参数
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web 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 Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m11s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m44s
AI Code Review / AI Code Review (pull_request) Successful in 6m42s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
与 get_rhythm_template(seed) 接口保持一致:传入 int 时自动构造
random.Random(seed),保证基于 variantSeed 可复现;Random 对象和
None 调用方式不变。
2026-09-07 22:35:49 +08:00
xiaoxia 6af2f3c08d Merge pull request 'fix: 修复 compute_duplicate_rate 方法丢失导致 27 个单测失败' (#1773) from fix/dedup-method-restore into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 23s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 12s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 35s
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 (push) Successful in 2m52s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m47s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m35s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (push) Successful in 3m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
AI Code Review / AI Code Review (pull_request) Successful in 7m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Unit Tests (push) Successful in 9m6s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m11s
CI/CD Pipeline / Validate - Security (push) Successful in 22m1s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-07 22:23:11 +08:00
CI Bot 410c390cf0 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m48s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m55s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m11s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m30s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 19m0s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (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 / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m28s
2026-09-07 14:03:49 +00:00
saas-backend-agent 9f85d40855 fix: 修复 compute_duplicate_rate 方法被错误嵌套到模块函数内部
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 34s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m46s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m55s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR #1762 查重增强时,compute_duplicate_rate 方法被意外放到
compute_structure_similarity 模块函数体内(return 之后的死代码),
导致 VideoDeduplicator 类丢失该方法,27 个旧单测 AttributeError。

将方法移回 VideoDeduplicator 类,缩进不变。
验证:test_duplicate_rate/test_duplicate_rate_scope/
test_dedup_1702/test_bad_fingerprint_filter/test_dedup_enhanced
共 78 个测试全部通过。
2026-09-07 22:01:13 +08:00
xiaoxia 034eaac695 Merge pull request 'feat: 像素级扰动滤镜降低平台查重风险 (Issue #1765)' (#1771) from feat/pixel-perturbation-1765 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 47s
CI/CD Pipeline / Build Staging API Image (push) Successful in 49s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m56s
CI/CD Pipeline / Validate - Style (push) Successful in 2m21s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
CI/CD Pipeline / Unit Tests (push) Failing after 8m12s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m22s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (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 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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging 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 / Production Browser E2E (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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 10s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 10s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Security (push) Successful in 22m17s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-07 20:56:11 +08:00
saas-backend-agent 27dccf6591 fix: #1769 智能剪辑 Step2 素材加载 400 错误
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 16s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 / 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 / Validate - Python (mypy + alembic) (push) Successful in 3m51s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m50s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m25s
CI/CD Pipeline / Unit Tests (push) Failing after 8m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m1s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 根因:模板 f7578c059c2e4dfca2236eb86cb6808a 是无效测试模板
  - is_active=false(已失效)
  - template_clip_configs=0(无片段配置)
  - template_segments=0(无旧版片段配置)
- 修复:_get_template_segments 添加详细错误日志,便于排查
- 前端应过滤 is_active=false 的模板
- 用户应选择 clip_config_count > 0 的有效模板
2026-09-07 20:38:35 +08:00
CI Bot ac7ab679b7 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 15s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m57s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m4s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m48s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m27s
AI Code Review / AI Code Review (pull_request) Successful in 6m40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m25s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 19m24s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 13s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m33s
2026-09-07 12:28:56 +00:00
saas-backend-agent bb98620a8a feat: 像素级扰动滤镜降低平台查重风险 (Issue #1765)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 20s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m36s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
- generate_pixel_perturbation():随机选 2-3 种滤镜组合
  - noise: 轻微噪声 (0.01~0.02)
  - unsharp: 锐化/柔化 (-0.5~+0.5)
  - curves: 对比度微调 (0.95~1.05)
  - color_balance: RGB 通道偏移
- _apply_pixel_perturbation():渲染时追加到 FFmpeg filter chain
- edit_plan_service:批量变体生成时为每个变体生成不同像素扰动
- 参数幅度确保肉眼不可见(SSIM > 0.95),帧级差异 > 3%
- 11 个新测试覆盖滤镜组合/参数范围/验收
2026-09-07 20:24:13 +08:00
xiaoxia 2a0b75c007 Merge pull request 'feat: 节奏模板让批量变体片段时长分布不同 (Issue #1764)' (#1770) from feat/rhythm-templates-1764 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 6s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 35s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 31s
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 / Integration Tests (push) Successful in 1m42s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Validate - Style (push) Successful in 2m41s
CI/CD Pipeline / Build Staging API Image (push) Successful in 42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 37s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
CI/CD Pipeline / Unit Tests (push) Failing after 11m13s
AI Code Review / AI Code Review (pull_request) Failing after 10m34s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Failing after 12m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m3s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m27s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m25s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-09-07 20:21:38 +08:00
CI Bot 0d9d4e584f style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 49s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 12m53s
Preview Cleanup / Cleanup Preview Environment (pull_request) Failing after 13m1s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 20m23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-09-07 12:20:53 +00:00
saas-backend-agent eb089fa26d feat: 节奏模板让批量变体片段时长分布不同 (Issue #1764)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web 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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 36s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 37s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m29s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- 新增 RHYTHM_TEMPLATES 节奏模板池(6 种权重序列)
- get_rhythm_template(seed):根据变体 seed 随机选模板
- adapt_template_length():将模板适配到实际片段数
- plan_clip_durations() 支持 rhythm_template 参数按权重分配时长
- 保底约束:每段 >= 2s,总时长 = 配音时长
- edit_plan_service: ensure_batch_variant_plans 为每个变体生成不同节奏模板
- 17 个新测试覆盖模板池/适配/分配/验收
2026-09-07 20:17:52 +08:00
saas-backend-agent 1ec274b9f5 ci: trigger push pipeline for dedup enhanced
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Frontend Lint (push) 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 Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 7s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 55s
CI/CD Pipeline / Validate - Style (push) Successful in 2m29s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 30s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (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
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
AI Code Review / AI Code Review (pull_request) Successful in 6m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Unit Tests (push) Failing after 7m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 6m11s
CI/CD Pipeline / Validate - Security (push) Successful in 16m42s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-07 19:51:52 +08:00
xiaoxia 62820391c3 fix: Step5 单视频全宽布局+视频播放器居中 (#1761)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Blocked by required conditions
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Blocked by required conditions
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Web Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Check push changed paths (push) Waiting to run
CI/CD Pipeline / Build Staging API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Retag skipped Staging API Image (push) Blocked by required conditions
CI/CD Pipeline / Retag skipped Staging Web Image (push) Blocked by required conditions
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
2026-09-07 19:51:42 +08:00
xiaoxia 322b0a082c Merge pull request 'feat(dedup): 增加文案+结构维度查重 (P2-后端3)' (#1762) from feat/dedup-enhanced-text-structure into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 16s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 58s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
2026-09-07 19:50:00 +08:00
CI Bot 07aa03da24 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 43s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m12s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m37s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m38s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m3s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m23s
AI Code Review / AI Code Review (pull_request) Successful in 6m26s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 16m23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-09-07 11:49:16 +00:00
saas-backend-agent add93bb9de feat(dedup): 增加文案+结构维度查重 (Issue #P2-后端3)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
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 / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 24s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m16s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- 新增 compute_text_similarity: 字符级 Jaccard 相似度比对配音文本
- 新增 compute_structure_similarity: 片段序列相似度(数量+类型+时长分布)
- 多维度融合公式: duplicate_rate = visual*0.5 + text*0.25 + structure*0.25
- 文案/结构数据缺失时降级为纯视觉维度
- 21 个新测试覆盖文本+结构+权重常量
2026-09-07 19:46:43 +08:00
xiaoxia 6fe3e03d6a Merge pull request 'feat: 封面智能选帧 — 帧质量评分(清晰度/亮度/色彩)' (#1759) from feat/cover-frame-scoring into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (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 Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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 / PR Build Worker Image (pull_request) Successful in 1m9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 39s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 34s
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 / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m51s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m43s
CI/CD Pipeline / Validate - Style (push) Successful in 2m52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m39s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m32s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
AI Code Review / AI Code Review (pull_request) Successful in 6m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Unit Tests (push) Successful in 8m37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m0s
CI/CD Pipeline / Validate - Security (push) Successful in 10m32s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-09-07 19:28:22 +08:00
xiaoxia d73c0a77d8 fix: Step5 BatchGenerationGrid 单卡居中 + maxWidth 240px (#1760)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m31s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m48s
CI/CD Pipeline / Validate - Style (push) Successful in 2m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m0s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m28s
CI/CD Pipeline / Validate - Security (push) Successful in 5m33s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m38s
CI/CD Pipeline / Unit Tests (push) Successful in 8m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 25s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 58s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m11s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m46s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m5s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m13s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m42s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m36s
CI/CD Pipeline / Build Production 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 / Canary Release to Production (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 / CI Gate (pull_request) Successful in 6s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 18:51:29 +08:00
122 changed files with 12543 additions and 718 deletions
@@ -0,0 +1,72 @@
"""Projects is_default + partial unique index for idempotent default project (Issue #1775)
Revision ID: 069_project_is_default
Revises: 068_user_profile_completed
Create Date: 2026-09-08
背景:
小程序端 getOrCreateDefaultProject 在重试/并发/前端重复调用下,
仅靠应用层"先查再插"不保证幂等,会给同一用户重复创建默认项目。
改动:
1. projects 表新增 is_default 布尔列(默认 false
2. 部分唯一索引 uq_projects_owner_default(owner_user_id) WHERE is_default = true
—— 保证每个用户至多一个默认项目
3. 存量数据回填:把名为"默认项目"的存量项目按创建时间最早者标记为 is_default=true
(只标记不删除;存量重复项目的清理另行确认后单独执行)
注意:部分唯一索引依赖 PostgreSQL,不支持 downgrade 到其他方言。
"""
import sqlalchemy as sa
from alembic import op
revision = "069_project_is_default"
down_revision = "068_user_profile_completed"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. 新增 is_default 列
op.add_column(
"projects",
sa.Column(
"is_default",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
# 2. 存量回填:每个拥有"默认项目"的用户,只把最早创建的那一个标记为默认。
# 用 ROW_NUMBER() 取每组第一条;非"默认项目"命名的项目不标记(保守,不动用户自建项目)。
op.execute("""
UPDATE projects p
SET is_default = true
WHERE p.id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY owner_user_id
ORDER BY created_at ASC, id ASC
) AS rn
FROM projects
WHERE name = '默认项目'
) t
WHERE t.rn = 1
)
""")
# 3. 部分唯一索引:每用户至多一个默认项目(只约束 is_default = true 的行)
op.execute("""
CREATE UNIQUE INDEX uq_projects_owner_default
ON projects (owner_user_id)
WHERE is_default = true
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_projects_owner_default")
op.drop_column("projects", "is_default")
+48
View File
@@ -0,0 +1,48 @@
"""Add scripts table for oral broadcast script library (Issue #1795)
Revision ID: 070_add_scripts
Revises: 069_project_is_default
Create Date: 2026-09-08
新建 scripts 表,支持口播文案 CRUD + 分段存储。
"""
import sqlalchemy as sa
from alembic import op
revision = "070_add_scripts"
down_revision = "069_project_is_default"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"scripts",
sa.Column("id", sa.String(36), nullable=False),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("content", sa.Text(), nullable=False, server_default=""),
sa.Column("segments", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_scripts_user_id", "scripts", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_scripts_user_id", table_name="scripts")
op.drop_table("scripts")
@@ -0,0 +1,47 @@
"""add lipsync jobs table
Revision ID: 071_add_lipsync_jobs
Revises: 070_add_scripts
Create Date: 2026-09-08
"""
import sqlalchemy as sa
from alembic import op
revision = "071_add_lipsync_jobs"
down_revision = "070_add_scripts"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"lipsync_jobs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
sa.Column("video_url", sa.Text(), nullable=False),
sa.Column("audio_url", sa.Text(), nullable=False),
sa.Column("enable_video_loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("mediakit_task_id", sa.String(200), nullable=False, server_default="", index=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
sa.Column("error_code", sa.String(100), nullable=False, server_default=""),
sa.Column("submitted_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# 复合索引:用户 + 状态(列表查询常用)
op.create_index("ix_lipsync_jobs_user_status", "lipsync_jobs", ["user_id", "status"])
# 项目 + 用户(项目维度查询)
op.create_index("ix_lipsync_jobs_project_user", "lipsync_jobs", ["project_id", "user_id"])
def downgrade() -> None:
op.drop_index("ix_lipsync_jobs_project_user", table_name="lipsync_jobs")
op.drop_index("ix_lipsync_jobs_user_status", table_name="lipsync_jobs")
op.drop_table("lipsync_jobs")
@@ -0,0 +1,48 @@
"""add ai avatar render jobs table
Revision ID: 072_add_ai_avatar_render
Revises: 071_add_lipsync_jobs
Create Date: 2026-09-09
"""
import sqlalchemy as sa
from alembic import op
revision = "072_add_ai_avatar_render"
down_revision = "071_add_lipsync_jobs"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"ai_avatar_render_jobs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
sa.Column("lipsync_job_id", sa.String(36), nullable=False),
sa.Column("script_id", sa.String(36), nullable=False),
sa.Column("b_roll_segments", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("cover_config", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("progress", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
sa.Column("output_cover_url", sa.Text(), nullable=False, server_default=""),
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
sa.Column("submitted_at", sa.DateTime(), nullable=True),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_ai_avatar_render_user_status", "ai_avatar_render_jobs", ["user_id", "status"])
op.create_index("ix_ai_avatar_render_project_user", "ai_avatar_render_jobs", ["project_id", "user_id"])
def downgrade() -> None:
op.drop_index("ix_ai_avatar_render_project_user", table_name="ai_avatar_render_jobs")
op.drop_index("ix_ai_avatar_render_user_status", table_name="ai_avatar_render_jobs")
op.drop_table("ai_avatar_render_jobs")
+18
View File
@@ -1,4 +1,5 @@
from app.api.routes.ai import router as ai_router
from app.api.routes.ai_avatar_render import router as ai_avatar_render_router
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
from app.api.routes.asset_libraries import router as asset_libraries_router
from app.api.routes.assets import router as assets_router
@@ -15,7 +16,9 @@ from app.api.routes.generation_variant_plans import router as generation_variant
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.lipsync import router as lipsync_router
from app.api.routes.projects import router as projects_router
from app.api.routes.scripts import router as scripts_router
from app.api.routes.share import router as share_router
from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
@@ -38,6 +41,11 @@ api_router.include_router(
auth_router,
tags=["Auth"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
projects_router,
prefix="/projects",
@@ -171,3 +179,13 @@ api_router.include_router(
internal_render_router,
tags=["Internal"],
)
api_router.include_router(
scripts_router,
prefix="/scripts",
tags=["ScriptLibrary"],
)
api_router.include_router(
ai_avatar_render_router,
prefix="/ai-avatar/render",
tags=["AI Avatar Render"],
)
+175
View File
@@ -0,0 +1,175 @@
"""AI数字人渲染合成 API 路由 — #1798.
接口:
POST /api/v1/ai-avatar/render 提交渲染任务
GET /api/v1/ai-avatar/render/jobs 任务列表
GET /api/v1/ai-avatar/render/{job_id} 任务详情
POST /api/v1/ai-avatar/render/{job_id}/cancel 取消任务
POST /api/v1/ai-avatar/render/{job_id}/retry 重试失败任务
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.ai_avatar_render import (
AiAvatarRenderJobResponse,
CreateAiAvatarRenderRequest,
)
from app.services.ai_avatar_render_service import (
AiAvatarRenderError,
AiAvatarRenderService,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService:
return AiAvatarRenderService(db)
# ── POST / — 提交渲染任务 ────────────────────────────────────────────────
@router.post("", response_model=AiAvatarRenderJobResponse, status_code=201)
def create_render_job(
body: CreateAiAvatarRenderRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: AiAvatarRenderService = Depends(_get_service),
):
"""提交 AI 数字人渲染任务.
将对口型视频 + B-roll 素材 + 标题叠加 + 封面提取合成最终输出视频。
"""
try:
job = svc.create_render_job(
user_id=current_user.id,
lipsync_job_id=body.lipsync_job_id,
script_id=body.script_id,
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
title_config=body.title_config,
cover_config=body.cover_config,
project_id=body.project_id,
)
except AiAvatarRenderError as exc:
status_map = {
"LipsyncJobNotFound": 404,
"LipsyncJobNotCompleted": 400,
"LipsyncJobNoOutput": 400,
"ScriptNotFound": 404,
}
raise HTTPException(
status_code=status_map.get(exc.code, 400),
detail={"code": exc.code, "message": str(exc)},
) from exc
# 异步触发渲染
try:
from app.tasks.ai_avatar_render import execute_ai_avatar_render
execute_ai_avatar_render.delay(job.id)
except Exception:
logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
return job
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
@router.get("/jobs", response_model=dict)
def list_render_jobs(
project_id: str = Query("", description="项目 ID 过滤"),
status: str = Query("", description="状态过滤"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: AuthenticatedUser = Depends(get_current_user),
svc: AiAvatarRenderService = Depends(_get_service),
):
"""获取 AI 数字人渲染任务列表."""
items, total = svc.list_render_jobs(
user_id=current_user.id,
project_id=project_id,
status=status,
offset=offset,
limit=limit,
)
return {
"items": [AiAvatarRenderJobResponse.model_validate(j) for j in items],
"total": total,
"offset": offset,
"limit": limit,
}
# ── GET /{job_id} — 任务详情 ─────────────────────────────────────────────
@router.get("/{job_id}", response_model=AiAvatarRenderJobResponse)
def get_render_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: AiAvatarRenderService = Depends(_get_service),
):
"""获取渲染任务详情."""
job = svc.get_render_job(job_id, current_user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
return job
# ── POST /{job_id}/cancel — 取消任务 ─────────────────────────────────────
@router.post("/{job_id}/cancel", response_model=AiAvatarRenderJobResponse)
def cancel_render_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: AiAvatarRenderService = Depends(_get_service),
):
"""取消渲染任务(仅 pending 状态可取消)."""
job = svc.cancel_render_job(job_id, current_user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "cancelled":
raise HTTPException(
status_code=400,
detail=f"任务状态 {job.status} 不可取消,仅 pending 可取消",
)
return job
# ── POST /{job_id}/retry — 重试失败任务 ──────────────────────────────────
@router.post("/{job_id}/retry", response_model=AiAvatarRenderJobResponse)
def retry_render_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: AiAvatarRenderService = Depends(_get_service),
):
"""重试失败的渲染任务."""
job = svc.retry_render_job(job_id, current_user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "pending":
raise HTTPException(
status_code=400,
detail=f"仅 failed 状态的任务可重试,当前状态: {job.status}",
)
# 重新触发渲染
try:
from app.tasks.ai_avatar_render import execute_ai_avatar_render
execute_ai_avatar_render.delay(job.id)
except Exception:
logger.warning("Celery 任务提交失败,重试任务已重置但未触发执行: %s", job.id)
return job
+5 -24
View File
@@ -20,7 +20,7 @@ from packages.application import (
GetProjectUseCase,
ListAssetLibrariesUseCase,
)
from packages.domain import AssetLibrary, AssetLibraryKind
from packages.domain import AssetLibraryKind
from ._helpers import check_project_access
@@ -120,30 +120,11 @@ def ensure_default_library(
kind = AssetLibraryKind(request.kind)
# 查找该项目下同 kind 的素材库,返回第一个
existing = asset_library_repository.find_by_project(request.project_id)
for lib in existing:
if lib.kind == kind:
return _to_asset_library_response(lib)
# 不存在 → 自动创建
import uuid
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
# Issue #1775: 幂等获取/创建——依赖唯一约束 uq_asset_libraries_project_kind
# 并发创建冲突时回滚重查返回已有记录,不再依赖应用层"先查后插",也不会 500。
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
library = AssetLibrary(
id=str(uuid.uuid4()),
project_id=request.project_id,
name=default_name,
kind=kind,
asset_count=0,
total_size=0,
created_at=now,
updated_at=now,
)
created = asset_library_repository.create(library)
return _to_asset_library_response(created)
library = asset_library_repository.get_or_create_default_library(request.project_id, kind, name=default_name)
return _to_asset_library_response(library)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
+145
View File
@@ -0,0 +1,145 @@
"""对口型 API 路由 — #1796 MediaKit 对口型.
接口:
POST /api/v1/lipsync/jobs 提交对口型任务
GET /api/v1/lipsync/jobs 任务列表
GET /api/v1/lipsync/jobs/{id} 任务详情
POST /api/v1/lipsync/jobs/{id}/refresh 刷新任务状态
POST /api/v1/lipsync/jobs/{id}/cancel 取消任务
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_service(db: Session = Depends(get_db_session)) -> LipsyncService:
return LipsyncService(db)
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
@router.post("/jobs", response_model=LipsyncJobResponse, status_code=201)
def create_lipsync_job(
body: CreateLipsyncJobRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""提交对口型任务.
输入人物视频 + 驱动音频,异步生成口型对齐视频。
"""
try:
job = svc.create_job(
user_id=current_user.id,
video_url=body.video_url,
audio_url=body.audio_url,
enable_video_loop=body.enable_video_loop,
project_id=body.project_id,
)
except MediaKitError as exc:
# 创建失败(job 已记录 error),返回 502
raise HTTPException(
status_code=502,
detail={
"code": exc.code,
"message": str(exc),
"request_id": exc.request_id,
},
) from exc
return job
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
@router.get("/jobs", response_model=dict)
def list_lipsync_jobs(
project_id: str = Query("", description="项目 ID 过滤"),
status: str = Query("", description="状态过滤"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""获取对口型任务列表."""
items, total = svc.list_jobs(
user_id=current_user.id,
project_id=project_id,
status=status,
offset=offset,
limit=limit,
)
return {
"items": [LipsyncJobResponse.model_validate(j) for j in items],
"total": total,
"offset": offset,
"limit": limit,
}
# ── GET /jobs/{job_id} — 任务详情 ────────────────────────────────────────
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
def get_lipsync_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""获取对口型任务详情."""
job = svc.get_job(job_id, current_user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
return job
# ── POST /jobs/{job_id}/refresh — 刷新状态 ───────────────────────────────
@router.post("/jobs/{job_id}/refresh", response_model=LipsyncJobResponse)
def refresh_lipsync_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""从 MediaKit 拉取最新状态并更新."""
job = svc.refresh_job_status(job_id, current_user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
return job
# ── POST /jobs/{job_id}/cancel — 取消任务 ────────────────────────────────
@router.post("/jobs/{job_id}/cancel", response_model=LipsyncJobResponse)
def cancel_lipsync_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""取消对口型任务(仅 pending/submitted 状态可取消)."""
job = svc.cancel_job(job_id, current_user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.status != "cancelled":
raise HTTPException(
status_code=400,
detail=f"任务状态 {job.status} 不可取消,仅 pending/submitted 可取消",
)
return job
+41 -1
View File
@@ -1,13 +1,14 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_project_repository
from app.dependencies import get_asset_library_repository, get_project_repository
from app.schemas.project import (
CreateProjectRequest,
ListProjectsResponse,
ProjectResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from packages.application import (
CreateProjectCommand,
@@ -16,10 +17,20 @@ from packages.application import (
GetProjectUseCase,
ListProjectsUseCase,
)
from packages.domain import AssetLibraryKind
router = APIRouter()
class DefaultContextResponse(BaseModel):
"""幂等默认上下文响应(Issue #1775):默认项目 + 各类型默认素材库 ID。"""
project_id: str
image_library_id: str
video_library_id: str
voice_library_id: str
def _to_project_response(item) -> ProjectResponse:
return ProjectResponse(
id=item.id,
@@ -72,6 +83,35 @@ def create_project(
return _to_project_response(project)
@router.post("/ensure-default", response_model=DefaultContextResponse)
def ensure_default_project_and_libraries(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> DefaultContextResponse:
"""幂等获取/创建当前用户的默认项目和三类默认素材库(Issue #1775)。
- 同一用户永远只有一个默认项目(部分唯一索引 uq_projects_owner_default
- 同一项目同 kind 永远只有一个默认素材库(唯一约束 uq_asset_libraries_project_kind
- 并发调用/失败重试:唯一约束冲突时返回已存在记录,不报 500
- 项目和素材库的创建各自在仓储事务内幂等,冲突回滚后重查返回同一条
"""
user_id = authenticated_user.user.id
project = project_repository.get_or_create_default_project(user_id)
libraries = {}
for kind in (AssetLibraryKind.VIDEO, AssetLibraryKind.VOICE, AssetLibraryKind.IMAGE):
library = asset_library_repository.get_or_create_default_library(project.id, kind)
libraries[kind] = library.id
return DefaultContextResponse(
project_id=project.id,
image_library_id=libraries[AssetLibraryKind.IMAGE],
video_library_id=libraries[AssetLibraryKind.VIDEO],
voice_library_id=libraries[AssetLibraryKind.VOICE],
)
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_project(
project_id: str,
+123
View File
@@ -0,0 +1,123 @@
"""Script (口播文案库) CRUD routes — Issue #1795."""
from __future__ import annotations
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.script import (
CreateScriptRequest,
ScriptListResponse,
ScriptResponse,
ScriptSegment,
UpdateScriptRequest,
)
from app.services.script_service import ScriptNotFoundError, ScriptService
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
router = APIRouter()
def _get_service(session: Session = Depends(get_db_session)) -> ScriptService:
return ScriptService(session)
def _to_response(script) -> ScriptResponse:
segments = script.segments or []
return ScriptResponse(
id=script.id,
user_id=script.user_id,
title=script.title,
content=script.content,
segments=[
ScriptSegment(text=s.get("text", ""), duration=s.get("duration")) if isinstance(s, dict) else s
for s in segments
],
tags=script.tags or [],
created_at=script.created_at,
updated_at=script.updated_at,
)
@router.get("", response_model=ScriptListResponse)
def list_scripts(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
tag: Optional[str] = Query(None, description="按标签筛选"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
svc: ScriptService = Depends(_get_service),
) -> ScriptListResponse:
user_id = authenticated_user.user.id
items, total = svc.list_scripts(user_id, skip=skip, limit=limit, tag=tag)
return ScriptListResponse(
items=[_to_response(i) for i in items],
total=total,
)
@router.post("", response_model=ScriptResponse, status_code=status.HTTP_201_CREATED)
def create_script(
request: CreateScriptRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
svc: ScriptService = Depends(_get_service),
) -> ScriptResponse:
user_id = authenticated_user.user.id
script = svc.create_script(
user_id=user_id,
title=request.title,
content=request.content,
segments=[s.model_dump() for s in request.segments],
tags=request.tags,
)
return _to_response(script)
@router.get("/{script_id}", response_model=ScriptResponse)
def get_script(
script_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
svc: ScriptService = Depends(_get_service),
) -> ScriptResponse:
user_id = authenticated_user.user.id
try:
script = svc.get_script(script_id, user_id)
except ScriptNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc
return _to_response(script)
@router.put("/{script_id}", response_model=ScriptResponse)
def update_script(
script_id: str,
request: UpdateScriptRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
svc: ScriptService = Depends(_get_service),
) -> ScriptResponse:
user_id = authenticated_user.user.id
try:
script = svc.update_script(
script_id=script_id,
user_id=user_id,
title=request.title,
content=request.content,
segments=[s.model_dump() for s in request.segments] if request.segments is not None else None,
tags=request.tags,
)
except ScriptNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc
return _to_response(script)
@router.delete("/{script_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_script(
script_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
svc: ScriptService = Depends(_get_service),
) -> Response:
user_id = authenticated_user.user.id
deleted = svc.delete_script(script_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found")
return
+5
View File
@@ -106,6 +106,10 @@ def list_templates(
tag: str | None = Query(None, description="按标签筛选"),
keyword: str | None = Query(None, description="按名称关键词搜索"),
mode: str | None = Query(None, description="按剪辑模式筛选"),
valid_only: bool = Query(
False,
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTemplatesResponse:
@@ -116,6 +120,7 @@ def list_templates(
tag=tag,
keyword=keyword,
mode=mode,
valid_only=valid_only,
)
use_case = ListTemplatesUseCase(template_repository)
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
@@ -36,17 +36,11 @@ from app.services.asset_segment_tracker import (
remove_used_segment,
)
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.plan_generator_utils import (
_calc_random_start_time,
build_scene_segments,
@@ -399,68 +393,42 @@ def _safe_segment_duration(value, default: float) -> float:
def _get_template_segments(
template_id: str,
user_id: str,
tpl_svc: EditTemplateService,
db: Session,
) -> list[tuple[int, float, float]]:
"""获取模板的片段配置(顺序、最短时长、最长时长).
优先从新模板系统(template_clip_configs)查询,
若不存在则回退到旧模板系统(template_segments)。
单一数据源:模板主表为 ``templates``(用户自建,归属 user_id/
``edit_templates``(全局模板库),片段配置主表为 ``template_clip_configs``
(由 ``EditTemplateService.list_clip_configs_for_editor`` 统一读取)。
不再使用"新表抛异常 → 降级直查配置表 → 再降级查 segments"的异常控制流,
也不在正常请求中打印 ``ValueError: 模板不存在`` 堆栈。
Args:
template_id: 模板 ID
user_id: 当前登录用户 ID(用于归属校验)
tpl_svc: 模板编辑器服务
Returns:
[(segment_order, duration_min, duration_max), ...] 按 order 排序
[(segment_order, duration_min, duration_max), ...] 按 order 排序
模板存在但未配置片段时返回空列表。
Raises:
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
"""
# 优先查新模板系统
try:
clip_configs = tpl_svc.list_clip_configs(template_id)
if clip_configs:
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
clip_configs = tpl_svc.list_clip_configs_for_editor(template_id, user_id)
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
try:
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
direct_configs = direct_repo.list_by_template(template_id)
if direct_configs:
result = []
for cc in direct_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
# 回退到旧模板系统(template_segments表)
try:
old_repo = SQLAlchemyTemplateRepository(db)
segments = old_repo.list_segments(template_id)
if segments:
result = []
for s in segments:
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((s.segment_order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("旧模板系统查询segments失败", exc_info=True)
return []
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
def _recommended_time_conflicts(
@@ -663,13 +631,21 @@ def create_clips_from_assets_editor(
7. 素材时长为 0 或缺失时报 400,不创建无效片段
"""
tpl_svc, plan_svc = services
user_id = str(current_user.user.id)
# 1. 查询模板 segments
segments = _get_template_segments(template_id, tpl_svc, db)
# 1. 查询模板片段配置。模板不存在/已删除/无权限 → 404;
# 模板存在但确实未配置片段 → 422(配置错误,与 404 区分)。
try:
segments = _get_template_segments(template_id, user_id, tpl_svc)
except TemplateNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在或无权访问",
) from exc
if not segments:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="模板没有片段配置,无法创建片段",
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="模板未配置片段",
)
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
@@ -41,29 +41,33 @@ def get_draft_plan_id(
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
确保 template_id → plan_id 的映射始终存在。
兼容策略:优先从新模板系统(edit_templates 表)查找,
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
模板读取遵循单一数据源、显式判定(不使用异常降级):
- 用户自建模板在旧表 ``templates``(归属 user_idis_active=True);
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
模板不存在、已删除或不归属于当前用户时,一律返回 404。
"""
tpl_svc, plan_svc = services
user_id = str(current_user.user.id)
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
old_repo = SQLAlchemyTemplateRepository(db)
old_template = old_repo.get_active(template_id, user_id)
is_global_template = tpl_svc.get_template(template_id) is not None
if old_template is None and not is_global_template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
# 1. 草稿已存在 → 直接返回
draft = tpl_svc.get_template_draft(template_id)
if draft is not None:
return draft.id
# 2. 新系统有模板 → 用新服务创建草稿
if tpl_svc.get_template(template_id) is not None:
# 2. 全局模板(新系统)→ 用新服务创建草稿
if is_global_template:
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
return draft.id
# 3. 回退到旧模板系统templates 表)
old_repo = SQLAlchemyTemplateRepository(db)
old_template = old_repo.get(template_id, user_id=user_id)
if old_template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
# 4. 基于旧模板创建草稿计划
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
from app.services.plan_generator_service import PlanGeneratorService
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
@@ -150,7 +150,7 @@ def rollback_template(
try:
tpl = tpl_svc.rollback_to_version(template_id, request.version)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
clip_configs = tpl_svc.list_clip_configs(template_id)
return EditorRollbackResponse(
+5
View File
@@ -208,6 +208,8 @@ def _create_pending_asset(
find-or-createprepare 阶段已按 file_hash/client_upload_id 预建的占位记录
会被 find_by_library_and_file_hash/find_by_library_and_client_upload_id 命中,
直接复用并补齐字段(避免 pre-create + complete 重复建两条)。
Issue #1776: 素材库计数由 asset_repository.create() 自动维护。
"""
# 1. 按 client_upload_id / file_hash 查找现有记录
existing = None
@@ -389,6 +391,7 @@ async def prepare_direct_upload(
file_size=request.file_size,
)
pending_asset_id = pending.id
# Issue #1776: 计数由 asset_repository.create() 自动维护
except Exception as error:
# 预建失败不阻塞签名:complete 仍可按 OSS 文件 + hash 兜底去重
logger.warning("预建 asset 占位失败,降级走 old flow: %s", error)
@@ -474,6 +477,7 @@ async def complete_direct_upload(
client_upload_id=request.client_upload_id,
file_size=request.file_size,
)
# Issue #1776: 计数由 asset_repository.create() 自动维护
job = _submit_ingest_job(
project_id=request.project_id,
@@ -568,6 +572,7 @@ async def upload_asset(
file_hash=file_hash,
client_upload_id=client_upload_id,
)
# Issue #1776: 计数由 asset_repository.create() 自动维护
job = _submit_ingest_job(
project_id=project_id,
+111
View File
@@ -0,0 +1,111 @@
"""AI数字人渲染合成管线 API Schema — #1798."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator
class BRollSegment(BaseModel):
"""B-roll 片段配置."""
script_segment_index: int = Field(..., ge=0, description="对应文案片段索引")
asset_url: str = Field(..., description="B-roll 素材 URL")
mode: str = Field(..., description="插入模式: fullscreen 或 pip")
start_time: float = Field(..., ge=0.0, description="在对口型视频中的起始时间(秒)")
end_time: float = Field(..., ge=0.0, description="在对口型视频中的结束时间(秒)")
pip_position: Optional[str] = Field("bottom_right", description="pip 模式位置")
pip_scale: Optional[float] = Field(0.3, ge=0.05, le=1.0, description="pip 模式缩放比例")
@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
v = v.strip().lower()
if v not in ("fullscreen", "pip"):
raise ValueError("mode 必须为 fullscreen 或 pip")
return v
@field_validator("asset_url")
@classmethod
def validate_asset_url(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("asset_url 不能为空")
if not v.startswith(("http://", "https://")):
raise ValueError("asset_url 必须是 HTTP/HTTPS URL")
return v
@field_validator("end_time")
@classmethod
def validate_end_time(cls, v: float, info: Any) -> float:
start = info.data.get("start_time", 0.0)
if v <= start:
raise ValueError("end_time 必须大于 start_time")
return v
class CreateAiAvatarRenderRequest(BaseModel):
"""创建渲染任务请求."""
lipsync_job_id: str = Field(..., description="对口型任务 ID")
script_id: str = Field(..., description="文案 ID")
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
title_config: dict[str, Any] = Field(default_factory=dict, description="标题配置")
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
project_id: str = Field("", description="项目 ID")
@field_validator("lipsync_job_id")
@classmethod
def validate_lipsync_job_id(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("lipsync_job_id 不能为空")
return v
@field_validator("script_id")
@classmethod
def validate_script_id(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("script_id 不能为空")
return v
class AiAvatarRenderJobResponse(BaseModel):
"""渲染任务响应."""
id: str
user_id: str
project_id: str
lipsync_job_id: str
script_id: str
b_roll_segments: list[dict[str, Any]]
title_config: dict[str, Any]
cover_config: dict[str, Any]
status: str
progress: int
output_video_url: str
output_cover_url: str
output_duration: float
error_message: str
submitted_at: Optional[datetime] = None
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class AiAvatarRenderProgressResponse(BaseModel):
"""渲染进度响应."""
status: str
progress: int
output_video_url: str
output_cover_url: str
output_duration: float
error_message: str
+70
View File
@@ -0,0 +1,70 @@
"""对口型 API Schema 定义 — #1796."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class LipsyncJobResponse(BaseModel):
"""对口型任务响应."""
id: str
user_id: str
project_id: str
video_url: str
audio_url: str
enable_video_loop: bool
mediakit_task_id: str
status: str
output_video_url: str
output_duration: float
error_message: str
error_code: str
submitted_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class CreateLipsyncJobRequest(BaseModel):
"""创建对口型任务请求."""
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
audio_url: str = Field(..., description="驱动音频 URLmp3/aac/wav/m4a/flac")
enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
project_id: str = Field("", description="项目 ID(可选)")
@field_validator("video_url")
@classmethod
def validate_video_url(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("video_url 不能为空")
if not v.startswith(("http://", "https://")):
raise ValueError("video_url 必须是 HTTP/HTTPS URL")
# 仅支持 MP4
lower = v.lower().split("?")[0]
if not lower.endswith(".mp4"):
raise ValueError("video_url 仅支持 MP4 格式")
return v
@field_validator("audio_url")
@classmethod
def validate_audio_url(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("audio_url 不能为空")
if not v.startswith(("http://", "https://")):
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
# 支持的音频格式
lower = v.lower().split("?")[0]
allowed_exts = (".mp3", ".aac", ".wav", ".m4a", ".flac")
if not any(lower.endswith(ext) for ext in allowed_exts):
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed_exts)}")
return v
+45
View File
@@ -0,0 +1,45 @@
"""Script (口播文案库) Pydantic schemas — Issue #1795."""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class ScriptSegment(BaseModel):
"""单段文案."""
text: str
duration: Optional[float] = None
class ScriptResponse(BaseModel):
id: str
user_id: str
title: str
content: str
segments: List[ScriptSegment] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class ScriptListResponse(BaseModel):
items: list[ScriptResponse]
total: int = 0
class CreateScriptRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
content: str = ""
segments: List[ScriptSegment] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list)
class UpdateScriptRequest(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
content: Optional[str] = None
segments: Optional[List[ScriptSegment]] = None
tags: Optional[List[str]] = None
@@ -0,0 +1,370 @@
"""AI数字人渲染合成 Service — #1798.
职责:
- 创建/查询/取消渲染任务
- 调用 Celery 异步任务执行渲染
- B-roll 合成 + 标题叠加 + 封面提取
- 用户隔离
"""
from __future__ import annotations
import logging
import os
import tempfile
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import (
AiAvatarRenderJob,
LipsyncJobModel,
ScriptModel,
)
from packages.domain.video_filter_builder import (
build_cover_extract_command,
build_title_drawtext_filter,
)
logger = logging.getLogger(__name__)
class AiAvatarRenderError(Exception):
"""渲染服务异常."""
def __init__(self, message: str, code: str = "RenderError"):
self.code = code
super().__init__(message)
class AiAvatarRenderService:
"""AI数字人渲染合成 Service."""
def __init__(self, db: Session):
self.db = db
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_render_job(
self,
*,
user_id: str,
lipsync_job_id: str,
script_id: str,
b_roll_segments: list[dict[str, Any]],
title_config: dict[str, Any],
cover_config: dict[str, Any],
project_id: str = "",
) -> AiAvatarRenderJob:
"""创建渲染任务.
Raises:
AiAvatarRenderError: 校验失败
"""
# 1. 验证对口型任务
lipsync_job = (
self.db.query(LipsyncJobModel)
.filter(
LipsyncJobModel.id == lipsync_job_id,
LipsyncJobModel.user_id == user_id,
)
.first()
)
if lipsync_job is None:
raise AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound")
if lipsync_job.status != "completed":
raise AiAvatarRenderError(
f"对口型任务状态为 {lipsync_job.status},仅 completed 状态可渲染",
code="LipsyncJobNotCompleted",
)
if not lipsync_job.output_video_url:
raise AiAvatarRenderError("对口型任务输出视频 URL 为空", code="LipsyncJobNoOutput")
# 2. 验证文案归属
script = (
self.db.query(ScriptModel)
.filter(
ScriptModel.id == script_id,
ScriptModel.user_id == user_id,
)
.first()
)
if script is None:
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
# 3. 创建渲染任务
job_id = str(uuid.uuid4())
job = AiAvatarRenderJob(
id=job_id,
user_id=user_id,
project_id=project_id,
lipsync_job_id=lipsync_job_id,
script_id=script_id,
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in b_roll_segments],
title_config=title_config,
cover_config=cover_config,
status="pending",
)
self.db.add(job)
self.db.flush()
job.submitted_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
# ── 查询任务 ──────────────────────────────────────────────────────────
def get_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
"""获取渲染任务详情(用户隔离)."""
return (
self.db.query(AiAvatarRenderJob)
.filter(
AiAvatarRenderJob.id == job_id,
AiAvatarRenderJob.user_id == user_id,
)
.first()
)
def list_render_jobs(
self,
*,
user_id: str,
project_id: str = "",
status: str = "",
offset: int = 0,
limit: int = 20,
) -> tuple[list[AiAvatarRenderJob], int]:
"""获取渲染任务列表(分页 + 用户隔离)."""
query = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.user_id == user_id)
if project_id:
query = query.filter(AiAvatarRenderJob.project_id == project_id)
if status:
query = query.filter(AiAvatarRenderJob.status == status)
total = query.count()
items = query.order_by(AiAvatarRenderJob.created_at.desc()).offset(offset).limit(limit).all()
return items, total
# ── 取消任务 ──────────────────────────────────────────────────────────
def cancel_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
"""取消渲染任务(仅 pending 状态可取消)."""
job = self.get_render_job(job_id, user_id)
if job is None:
return None
if job.status in ("pending", "submitted"):
job.status = "cancelled"
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
# ── 重试任务 ──────────────────────────────────────────────────────────
def retry_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
"""重试失败的渲染任务."""
job = self.get_render_job(job_id, user_id)
if job is None:
return None
if job.status != "failed":
return None
job.status = "pending"
job.progress = 0
job.error_message = ""
job.output_video_url = ""
job.output_cover_url = ""
job.output_duration = 0.0
job.started_at = None
job.completed_at = None
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
# ── 执行渲染(Celery 异步调用) ──────────────────────────────────────
def execute_render(self, job_id: str) -> None:
"""执行渲染管线.
由 Celery 异步任务调用,流程:
1. 下载对口型输出视频 (20%)
2. 构建 FFmpeg 滤镜链 (40%)
3. 执行 FFmpeg 渲染 (80%)
4. 提取封面 (90%)
5. 上传到 OSS (95%)
6. 更新任务状态 (100%)
"""
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
if job is None:
logger.error("渲染任务不存在: %s", job_id)
return
if job.status == "cancelled":
logger.info("渲染任务已取消: %s", job_id)
return
try:
# 更新状态为 processing
job.status = "processing"
job.started_at = datetime.now(timezone.utc)
job.progress = 5
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
# 获取对口型任务信息
lipsync_job = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job.lipsync_job_id).first()
if lipsync_job is None:
raise AiAvatarRenderError("关联的对口型任务不存在", code="LipsyncJobNotFound")
# 1. 下载对口型输出视频 (20%)
input_video_path = self._download_video(lipsync_job.output_video_url)
job.progress = 20
self.db.commit()
# 2. 构建 FFmpeg 滤镜链 (40%)
from packages.domain.video_filter_builder import build_broll_overlay_filter
filter_complex = build_broll_overlay_filter(
b_roll_segments=job.b_roll_segments,
video_duration=lipsync_job.output_duration,
)
# 标题叠加
title_filter = build_title_drawtext_filter(job.title_config)
if title_filter:
if filter_complex:
filter_complex += f"[vout]{title_filter}[vout_titled];"
else:
filter_complex = f"[0:v]{title_filter}[vout_titled];"
# 清理末尾分号
if filter_complex.endswith(";"):
filter_complex = filter_complex[:-1]
# 最终输出标签
final_label = "vout_titled" if title_filter else ("vout" if filter_complex else None)
job.progress = 40
self.db.commit()
# 3. 执行 FFmpeg 渲染 (80%)
with tempfile.TemporaryDirectory() as tmpdir:
output_video_path = os.path.join(tmpdir, "output.mp4")
cmd = self._build_ffmpeg_command(
input_video=input_video_path,
b_roll_segments=job.b_roll_segments,
filter_complex=filter_complex,
final_label=final_label,
output_path=output_video_path,
)
exit_code = os.system(cmd)
if exit_code != 0:
raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
job.progress = 80
self.db.commit()
# 4. 提取封面 (90%)
cover_path = ""
if job.cover_config:
cover_path = os.path.join(tmpdir, "cover.jpg")
cover_cmd = build_cover_extract_command(job.cover_config, cover_path)
cover_cmd = cover_cmd.replace("INPUT_VIDEO", output_video_path)
cover_exit = os.system(cover_cmd)
if cover_exit != 0:
logger.warning("封面提取失败,跳过: %s", cover_cmd)
cover_path = ""
job.progress = 90
self.db.commit()
# 5. 上传到 OSS (95%)
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
job.output_video_url = output_video_url
if cover_path:
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
job.output_cover_url = output_cover_url
# 获取输出视频时长
job.output_duration = lipsync_job.output_duration
job.progress = 95
self.db.commit()
# 6. 完成
job.status = "completed"
job.progress = 100
job.completed_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
logger.info("渲染任务完成: %s", job_id)
except AiAvatarRenderError as exc:
job.status = "failed"
job.error_message = str(exc)
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
logger.error("渲染任务失败 [%s]: %s", job_id, exc)
except Exception as exc:
job.status = "failed"
job.error_message = f"渲染异常: {str(exc)}"
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
logger.exception("渲染任务异常 [%s]", job_id)
def _download_video(self, url: str) -> str:
"""下载视频到临时文件."""
import httpx
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
try:
with httpx.Client(timeout=120) as client:
resp = client.get(url)
resp.raise_for_status()
tmp.write(resp.content)
return tmp.name
except Exception:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
raise
def _build_ffmpeg_command(
self,
*,
input_video: str,
b_roll_segments: list[dict[str, Any]],
filter_complex: str,
final_label: Optional[str],
output_path: str,
) -> str:
"""构建 FFmpeg 命令."""
# 输入文件
inputs = f"-i {input_video}"
for seg in b_roll_segments:
asset_url = seg.get("asset_url", "")
if asset_url:
inputs += f" -i {asset_url}"
# 滤镜
if filter_complex and final_label:
filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"'
elif filter_complex:
filter_arg = f'-filter_complex "{filter_complex}"'
else:
filter_arg = ""
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset fast -crf 23 -y {output_path}"
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
"""上传文件到 OSS,返回 URL.
简化实现,实际应调用 OSS SDK。
"""
# TODO: 集成实际 OSS 上传
logger.info("上传文件到 OSS: %s -> %s", local_path, oss_key)
return f"https://oss.example.com/{oss_key}"
+68 -9
View File
@@ -784,19 +784,27 @@ class EditPlanService:
from packages.domain.voice_duration_planner import plan_clip_durations, total_output_duration
# #1764:从 plan config 读取节奏模板
rhythm_template = None
if plan and hasattr(plan, "config") and plan.config:
rhythm_template = plan.config.get("rhythm_template")
# #1768:先获取素材时长,传入 plan_clip_durations 用于最大片段钳制
asset_ids = [c.asset_id for c in clips if c.asset_id]
durations = self.get_asset_durations(asset_ids)
asset_durations_for_plan = [durations.get(c.asset_id, 0.0) for c in clips]
target = plan_clip_durations(
len(clips),
voice,
transition_effects=[c.transition_effect for c in clips],
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
rhythm_template=rhythm_template,
asset_durations=asset_durations_for_plan,
)
if not target:
return None
# 素材时长(短素材起点钳 0
asset_ids = [c.asset_id for c in clips if c.asset_id]
durations = self.get_asset_durations(asset_ids)
clips_data: list[dict] = []
for i, c in enumerate(clips):
dur = float(target[i])
@@ -907,6 +915,36 @@ class EditPlanService:
)
plan_ids.append(variant.id)
# #1764:为每个变体生成独立节奏模板(让批量视频片段时长分布不同)
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
clip_count = 0
if voice_durations and len(voice_durations) > 0:
# 从源 plan 获取片段数
source_plan = self.get_plan(source_plan_id)
if source_plan and hasattr(source_plan, "clips"):
clip_count = len(list(source_plan.clips)) if source_plan.clips else 0
rhythm_templates_for_variants = []
if clip_count > 0:
for idx in range(len(plan_ids)):
# 每个变体用不同的 seed 选择节奏模板
variant_seed = rng.randint(0, 999999)
template = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
rhythm_templates_for_variants.append(template)
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
# #1767:BGM 池差异化分配(让批量变体使用不同 BGM / 段落 / 音量)
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
source_bgm_config = {}
source_plan = self.get_plan(source_plan_id)
if source_plan and source_plan.config:
source_bgm_config = source_plan.config.get("bgm", {}) or {}
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in plan_ids]
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
from packages.domain.variant_plan_selector import generate_visual_perturbation
@@ -916,8 +954,29 @@ class EditPlanService:
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
if idx == 0:
perturbation["hflip"] = False
self.update_plan_config(pid, {"visual_perturbation": perturbation})
logger.info("变体 %d 视觉扰动: plan=%s perturbation=%s", idx, pid, perturbation)
config_update = {"visual_perturbation": perturbation}
# #1764:写入节奏模板
if idx < len(rhythm_templates_for_variants):
config_update["rhythm_template"] = rhythm_templates_for_variants[idx]
# #1765:写入像素级扰动滤镜
from packages.domain.variant_plan_selector import generate_pixel_perturbation
pixel_pert = generate_pixel_perturbation(rng)
config_update["pixel_perturbation"] = pixel_pert
# #1767:写入 BGM 池分配(覆盖 bgm 配置中的 preset_id / audio_offset / volume_adjust_db
if idx < len(bgm_pool_assignments):
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
existing_bgm.update(bgm_pool_assignments[idx])
config_update["bgm"] = existing_bgm
self.update_plan_config(pid, config_update)
logger.info(
"变体 %d 视觉扰动+像素扰动+BGM池: plan=%s vis=%s pix=%s bgm=%s",
idx,
pid,
perturbation,
pixel_pert,
bgm_pool_assignments[idx] if idx < len(bgm_pool_assignments) else None,
)
except Exception:
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
@@ -1153,7 +1212,7 @@ class EditPlanService:
config_asset_ids_count = len((plan.config or {}).get("asset_ids", []))
clips_with_asset_count = sum(1 for c in clips if c.asset_id)
logger.info(
"can_generate 诊断: plan=%s status=%s total_clips=%d " "clips_with_asset=%d config_asset_ids_count=%d",
"can_generate 诊断: plan=%s status=%s total_clips=%d clips_with_asset=%d config_asset_ids_count=%d",
plan_id,
plan.status,
len(clips),
@@ -1165,7 +1224,7 @@ class EditPlanService:
config_asset_ids = (plan.config or {}).get("asset_ids", [])
if config_asset_ids:
logger.warning(
"can_generate 最后防线触发: plan=%s clips=%d 均无素材," "从 config.asset_ids(%d个) 自动分配",
"can_generate 最后防线触发: plan=%s clips=%d 均无素材,从 config.asset_ids(%d个) 自动分配",
plan_id,
len(clips),
len(config_asset_ids),
@@ -1197,7 +1256,7 @@ class EditPlanService:
return False, "没有可渲染的就绪片段,自动修复后仍未分配素材"
else:
logger.warning(
"can_generate 失败: plan=%s clips=%d 均无素材," "且 config.asset_ids 为空,无法自动修复",
"can_generate 失败: plan=%s clips=%d 均无素材,且 config.asset_ids 为空,无法自动修复",
plan_id,
len(clips),
)
+65 -1
View File
@@ -34,6 +34,17 @@ from packages.domain.template_clip_converter import (
logger = logging.getLogger(__name__)
class TemplateNotFoundError(Exception):
"""模板不存在、已删除或当前用户无权访问.
"模板存在但无片段配置"区分:路由层应映射为 HTTP 404。
"""
def __init__(self, template_id: str) -> None:
self.template_id = template_id
super().__init__(f"模板不存在: {template_id}")
class EditTemplateService:
"""模板管理服务
@@ -217,7 +228,14 @@ class EditTemplateService:
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
"""列出模板的片段配置"""
"""列出模板的片段配置
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
主要服务于新模板系统的写入/发布路径。用户自建模板存放在旧表
``templates``,不在 ``edit_templates`` 中,读取其片段配置请改用
:meth:`list_clip_configs_for_editor`,后者直接读取片段配置主表
``template_clip_configs``,不依赖新模板主表、也不靠异常降级。
"""
# 确保模板存在
self.get_template_or_raise(template_id)
return self._clip_config_repo.list_by_template(
@@ -227,6 +245,52 @@ class EditTemplateService:
limit=limit,
)
def list_clip_configs_for_editor(
self,
template_id: str,
user_id: str,
*,
clip_type: Optional[ClipType] = None,
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
"""编辑器读取模板片段配置的单一数据源入口.
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
模板主表按双表现状显式判定,不使用 try/except 控制流:
1. 用户自建模板在旧表 ``templates``(归属 user_id)→ 校验归属与未删除后直接读;
2. 全局模板在新表 ``edit_templates``(无 user_id,全局可读)→ 直接读;
3. 两者都没有 → 模板不存在/无权限,抛 :class:`TemplateNotFoundError`。
Args:
template_id: 模板 ID
user_id: 当前登录用户 ID(用于旧表模板归属校验)
Raises:
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
"""
# 1) 用户自建模板(旧表 templates,归属 user_id
if self._clip_config_repo.template_owned_by(template_id, user_id):
return self._clip_config_repo.list_by_template(
template_id,
clip_type=clip_type,
skip=skip,
limit=limit,
)
# 2) 全局模板(新表 edit_templates,无 user_id,全局可读)
if self._template_repo.get(template_id) is not None:
return self._clip_config_repo.list_by_template(
template_id,
clip_type=clip_type,
skip=skip,
limit=limit,
)
# 3) 两表都没有:不存在 / 已删除 / 无权限
raise TemplateNotFoundError(template_id)
def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
"""获取片段配置详情"""
return self._clip_config_repo.get(config_id)
+181
View File
@@ -0,0 +1,181 @@
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
职责:
- 创建/查询/取消对口型任务
- 调用 MediaKit 客户端提交异步任务
- 轮询更新任务状态
- 用户隔离(每个用户只能操作自己的任务)
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional
from app.services.mediakit_client import (
STATUS_COMPLETED,
STATUS_FAILED,
STATUS_RUNNING,
MediaKitClient,
MediaKitError,
get_mediakit_client,
)
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
logger = logging.getLogger(__name__)
class LipsyncService:
"""对口型任务 Service."""
def __init__(self, db: Session, client: Optional[MediaKitClient] = None):
self.db = db
self.client = client or get_mediakit_client()
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_job(
self,
*,
user_id: str,
video_url: str,
audio_url: str,
enable_video_loop: bool = False,
project_id: str = "",
) -> LipsyncJobModel:
"""创建对口型任务并提交到 MediaKit.
Raises:
MediaKitError: API 调用失败
"""
# 1. 创建数据库记录
job_id = str(uuid.uuid4())
job = LipsyncJobModel(
id=job_id,
user_id=user_id,
project_id=project_id,
video_url=video_url,
audio_url=audio_url,
enable_video_loop=enable_video_loop,
status="pending",
)
self.db.add(job)
self.db.flush()
# 2. 提交到 MediaKit
try:
result = self.client.submit_lipsync(
video_url=video_url,
audio_url=audio_url,
enable_video_loop=enable_video_loop,
client_token=job_id, # 幂等控制
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
logger.error("提交对口型任务失败: %s", exc)
raise
self.db.commit()
self.db.refresh(job)
return job
# ── 查询任务 ──────────────────────────────────────────────────────────
def get_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
"""获取任务详情(用户隔离)."""
return (
self.db.query(LipsyncJobModel)
.filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id)
.first()
)
def list_jobs(
self,
*,
user_id: str,
project_id: str = "",
status: str = "",
offset: int = 0,
limit: int = 20,
) -> tuple[list[LipsyncJobModel], int]:
"""获取任务列表(分页 + 用户隔离)."""
query = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.user_id == user_id)
if project_id:
query = query.filter(LipsyncJobModel.project_id == project_id)
if status:
query = query.filter(LipsyncJobModel.status == status)
total = query.count()
items = query.order_by(LipsyncJobModel.created_at.desc()).offset(offset).limit(limit).all()
return items, total
# ── 更新任务状态(轮询) ──────────────────────────────────────────────
def refresh_job_status(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
"""从 MediaKit 拉取最新状态并更新本地记录.
Returns:
更新后的 Job,或 None(任务不存在/不属于该用户)
"""
job = self.get_job(job_id, user_id)
if job is None:
return None
# 终态不需要再轮询
if job.status in (STATUS_COMPLETED, "failed"):
return job
# 未提交的任务不轮询
if not job.mediakit_task_id:
return job
try:
status_data = self.client.get_task_status(job.mediakit_task_id)
except MediaKitError as exc:
logger.error("轮询对口型任务状态失败 [%s]: %s", job_id, exc)
return job
mk_status = status_data.get("status", STATUS_RUNNING)
if mk_status == STATUS_COMPLETED:
result = status_data.get("result", {})
job.status = STATUS_COMPLETED
job.output_video_url = result.get("video_url", "")
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(timezone.utc)
elif mk_status == STATUS_FAILED:
error = status_data.get("error", {})
job.status = "failed"
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(timezone.utc)
# running 状态只更新时间戳
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
# ── 取消任务 ──────────────────────────────────────────────────────────
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
"""取消任务(仅 pending/submitted 状态可取消)."""
job = self.get_job(job_id, user_id)
if job is None:
return None
if job.status in ("pending", "submitted"):
job.status = "cancelled"
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
+243
View File
@@ -0,0 +1,243 @@
"""MediaKit 客户端 — 封装火山引擎 AI MediaKit 对口型 API.
接口文档:https://docs.volcengine.com/docs/6448/2656064
异步任务流程:
1. POST /api/v1/tools/lip-sync 提交对口型任务 → 返回 task_id
2. GET /api/v1/tasks/{task_id} 轮询任务状态 → running/completed/failed
3. completed 时 result.video_url 为口型对齐视频(临时链接 24h 有效)
设计原则:
- API Key 从配置读取(settings.mediakit_api_key
- 未配置 API Key 时所有方法返回降级响应,不阻塞主流程
- HTTP 超时/网络异常统一包装为 MediaKitError
"""
from __future__ import annotations
import logging
from typing import Any, Optional
import httpx
from packages.config import get_api_settings
logger = logging.getLogger(__name__)
# ── 任务状态常量 ──────────────────────────────────────────────────────────
STATUS_RUNNING = "running"
STATUS_COMPLETED = "completed"
STATUS_FAILED = "failed"
class MediaKitError(Exception):
"""MediaKit API 调用异常."""
def __init__(self, message: str, code: str = "", request_id: str = ""):
self.code = code
self.request_id = request_id
super().__init__(message)
class MediaKitClient:
"""火山引擎 AI MediaKit 对口型 API 客户端.
用法:
client = get_mediakit_client()
result = client.submit_lipsync(video_url="...", audio_url="...")
task_id = result["task_id"]
status = client.get_task_status(task_id)
# {"status": "completed", "result": {"video_url": "...", "duration": 60.5}}
"""
def __init__(self) -> None:
settings = get_api_settings()
self._api_key = settings.mediakit_api_key
self._base_url = settings.mediakit_base_url.rstrip("/")
self._timeout = settings.mediakit_timeout
@property
def is_available(self) -> bool:
"""是否已配置 API Key(未配置时自动降级)."""
return bool(self._api_key)
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
# ── 提交对口型任务 ────────────────────────────────────────────────────
def submit_lipsync(
self,
*,
video_url: str,
audio_url: str,
enable_video_loop: bool = False,
callback_url: Optional[str] = None,
callback_args: Optional[str] = None,
client_token: Optional[str] = None,
) -> dict[str, Any]:
"""提交视频口型对齐任务.
Args:
video_url: 人物视频 URLMP4,≤30min,单人真人)
audio_url: 驱动音频 URLmp3/aac/wav/m4a/flac
enable_video_loop: 音频长于视频时是否循环画面
callback_url: 任务完成回调 URL
callback_args: 回调时原样返回的自定义参数
client_token: 幂等控制 token
Returns:
{"success": True, "task_id": "...", "request_id": "..."}
Raises:
MediaKitError: API 调用失败
"""
if not self.is_available:
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
payload: dict[str, Any] = {
"video_url": video_url,
"audio_url": audio_url,
}
if enable_video_loop:
payload["enable_video_loop"] = True
if callback_url:
payload["callback_url"] = callback_url
if callback_args:
payload["callback_args"] = callback_args[:512] # API 限制 512 字节
if client_token:
payload["client_token"] = client_token[:64] # API 限制 64 字符
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(
f"{self._base_url}/tools/lip-sync",
headers=self._headers(),
json=payload,
)
resp.raise_for_status()
data = resp.json()
except httpx.TimeoutException as exc:
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
except httpx.HTTPStatusError as exc:
body = exc.response.text[:500]
raise MediaKitError(
f"MediaKit API HTTP {exc.response.status_code}: {body}",
code="HttpError",
) from exc
except httpx.RequestError as exc:
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
except Exception as exc:
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
if not data.get("success"):
error = data.get("error", {})
raise MediaKitError(
error.get("message", "提交任务失败"),
code=error.get("code", "SubmitFailed"),
request_id=data.get("request_id", ""),
)
return {
"success": True,
"task_id": data["task_id"],
"request_id": data.get("request_id", ""),
}
# ── 查询任务状态 ──────────────────────────────────────────────────────
def get_task_status(self, task_id: str) -> dict[str, Any]:
"""查询异步任务状态和结果.
Args:
task_id: 提交任务时返回的任务 ID
Returns:
{
"success": True,
"task_id": "...",
"status": "running" | "completed" | "failed",
"result": {"video_url": "...", "duration": 60.5} | None,
"error": {"code": "...", "message": "..."} | None,
"created_at": 1777291767,
"finished_at": 1777291851 | None,
"expires_at": 1777464650 | None,
}
Raises:
MediaKitError: API 调用失败
"""
if not self.is_available:
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.get(
f"{self._base_url}/tasks/{task_id}",
headers=self._headers(),
)
resp.raise_for_status()
data = resp.json()
except httpx.TimeoutException as exc:
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
except httpx.HTTPStatusError as exc:
body = exc.response.text[:500]
raise MediaKitError(
f"MediaKit API HTTP {exc.response.status_code}: {body}",
code="HttpError",
) from exc
except httpx.RequestError as exc:
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
except Exception as exc:
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
if not data.get("success"):
error = data.get("error", {})
raise MediaKitError(
error.get("message", "查询任务失败"),
code=error.get("code", "QueryFailed"),
request_id=data.get("request_id", ""),
)
result: dict[str, Any] = {
"success": True,
"task_id": data.get("task_id", task_id),
"status": data.get("status", STATUS_RUNNING),
"result": data.get("result"),
"created_at": data.get("created_at"),
"finished_at": data.get("finished_at"),
"expires_at": data.get("expires_at"),
}
# 失败时提取错误信息
if data.get("status") == STATUS_FAILED:
error_obj = data.get("error", {})
result["error"] = {
"code": error_obj.get("code", "TaskFailed"),
"message": error_obj.get("message", "任务执行失败"),
}
return result
# ── 单例 ──────────────────────────────────────────────────────────────────
_client: Optional[MediaKitClient] = None
def get_mediakit_client() -> MediaKitClient:
"""获取 MediaKit 客户端单例."""
global _client
if _client is None:
_client = MediaKitClient()
return _client
def reset_mediakit_client() -> None:
"""重置客户端(测试用)."""
global _client
_client = None
+109
View File
@@ -0,0 +1,109 @@
"""ScriptService — Issue #1795 口播文案库 CRUD.
纯 Service 层封装,routes 直接调用。
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import ScriptModel
class ScriptNotFoundError(Exception):
"""文案不存在或不属于当前用户."""
class ScriptService:
"""口播文案 CRUD."""
def __init__(self, db: Session) -> None:
self.db = db
# ── list ──────────────────────────────────────────────────────────────
def list_scripts(
self,
user_id: str,
skip: int = 0,
limit: int = 50,
tag: Optional[str] = None,
) -> tuple[list[ScriptModel], int]:
"""返回 (items, total)."""
q = self.db.query(ScriptModel).filter(ScriptModel.user_id == user_id)
if tag:
# JSON 数组包含查询
q = q.filter(ScriptModel.tags.contains([tag]))
total = q.count()
items = q.order_by(ScriptModel.created_at.desc()).offset(skip).limit(limit).all()
return items, total
# ── create ────────────────────────────────────────────────────────────
def create_script(
self,
user_id: str,
title: str,
content: str = "",
segments: list | None = None,
tags: list | None = None,
) -> ScriptModel:
script = ScriptModel(
id=str(uuid.uuid4()),
user_id=user_id,
title=title,
content=content,
segments=segments if segments is not None else [],
tags=tags if tags is not None else [],
)
self.db.add(script)
self.db.commit()
self.db.refresh(script)
return script
# ── get ───────────────────────────────────────────────────────────────
def get_script(self, script_id: str, user_id: str) -> ScriptModel:
script = self.db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first()
if script is None:
raise ScriptNotFoundError(f"Script {script_id} not found")
return script
# ── update ────────────────────────────────────────────────────────────
def update_script(
self,
script_id: str,
user_id: str,
title: Optional[str] = None,
content: Optional[str] = None,
segments: Optional[list] = None,
tags: Optional[list] = None,
) -> ScriptModel:
script = self.get_script(script_id, user_id)
if title is not None:
script.title = title
if content is not None:
script.content = content
if segments is not None:
script.segments = segments
if tags is not None:
script.tags = tags
script.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(script)
return script
# ── delete ────────────────────────────────────────────────────────────
def delete_script(self, script_id: str, user_id: str) -> bool:
script = self.db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first()
if script is None:
return False
self.db.delete(script)
self.db.commit()
return True
@@ -39,6 +39,9 @@ from packages.domain.video_filter_builder import (
)
from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func
from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex
from packages.domain.video_filter_builder import (
build_title_drawtext_filter,
)
from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func
from packages.domain.video_filter_builder import chain_filters as _chain_filters_func
from packages.domain.video_filter_builder import has_audio as _has_audio_func
@@ -248,6 +251,27 @@ class VideoComposeService:
transitions=[c.transition_effect for c in ready_clips],
)
# ── #1789 标题 drawtext 滤镜叠加 ──
# 从 plan.config 读取 title_config,生成 drawtext 滤镜链入 filter_complex
title_cfg = (plan.config or {}).get("title", {}) or {}
if not isinstance(title_cfg, dict):
title_cfg = {}
# 同时兼容 plan.config["title_config"]API 回写路径)
if not title_cfg.get("text") and not title_cfg.get("content"):
title_cfg_alt = (plan.config or {}).get("title_config", {}) or {}
if isinstance(title_cfg_alt, dict) and (title_cfg_alt.get("text") or title_cfg_alt.get("content")):
title_cfg = title_cfg_alt
drawtext_filter = build_title_drawtext_filter(title_cfg, output_width, output_height)
if drawtext_filter:
# 将最终输出标签从 [outv] 改为 [composed],再链入 drawtext → [outv]
filter_complex = filter_complex.replace("[outv]", "[composed]")
filter_complex += f";[composed]{drawtext_filter}[outv]"
logger.info(
"[#1789] 标题 drawtext 滤镜已注入: plan_id=%s text=%s",
plan_id,
(title_cfg.get("text") or title_cfg.get("content") or "")[:30],
)
# 构建完整命令
command: list[str] = ["ffmpeg", "-y"]
+1
View File
@@ -0,0 +1 @@
"""Celery 异步任务模块."""
+48
View File
@@ -0,0 +1,48 @@
"""AI数字人渲染 Celery 异步任务 — #1798."""
from __future__ import annotations
import logging
from app.core.celery_app import celery_app
from app.dependencies import get_db_session
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="ai_avatar_render.execute", max_retries=2)
def execute_ai_avatar_render(self, job_id: str) -> dict:
"""执行 AI 数字人渲染管线.
进度更新:
- 0%: 任务开始
- 20%: 下载对口型视频完成
- 40%: 滤镜链构建完成
- 80%: FFmpeg 渲染完成
- 95%: 上传 OSS 完成
- 100%: 任务完成
"""
logger.info("开始执行渲染任务: %s", job_id)
self.update_state(state="PROCESSING", meta={"progress": 0, "job_id": job_id})
try:
# 获取数据库 session
db_gen = get_db_session()
db = next(db_gen)
try:
from app.services.ai_avatar_render_service import AiAvatarRenderService
service = AiAvatarRenderService(db)
service.execute_render(job_id)
finally:
try:
next(db_gen)
except StopIteration:
pass
return {"status": "completed", "job_id": job_id}
except Exception as exc:
logger.exception("渲染任务执行异常 [%s]: %s", job_id, exc)
self.update_state(state="FAILED", meta={"progress": 0, "error": str(exc)})
raise
+16 -4
View File
@@ -5,10 +5,22 @@ import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { AssetLibraryItem } from "./types"
/** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get("/asset-libraries")
return response.data.items || []
/**
* 获取当前用户的素材库
*
* @param kind 可选,按素材库类型过滤(video/voice/image)。
* 后端 GET /asset-libraries 支持 kind 查询参数;这里同时在前端再按返回数据的
* kind 字段兜底过滤一次,保证旧后端(忽略未知 query 参数)也不会把其他类型的库
* 混进来(#1777:视频选择器只展示视频库)。
*/
export const getAssetLibraries = async (
kind?: AssetLibraryItem["kind"],
): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get<{ items?: AssetLibraryItem[] }>("/asset-libraries", {
params: kind ? { kind } : undefined,
})
const items = response.data.items || []
return kind ? items.filter((lib) => lib.kind === kind) : items
}
/** 创建素材库(自动获取或创建默认项目以提供 project_id */
+13
View File
@@ -55,6 +55,19 @@ apiClient.interceptors.response.use(
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean
/**
* 调用方自行处理错误提示时置 true:拦截器跳过全局 message 弹窗(#1777)。
* 例如失效模板自动回退时,调用方会弹「原模板已失效,已自动切换」,
* 不再叠加后端原始错误文案。错误仍会 reject,不影响 catch 逻辑。
*/
_silentErrorToast?: boolean
}
// 调用方声明自行处理提示:标记为已展示,跳过下面所有全局 message 弹窗
if (originalRequest?._silentErrorToast) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(error as any).__msgShown = true
return Promise.reject(error)
}
// 401 → 尝试刷新 Token
@@ -12,17 +12,25 @@ import type {
ListCategoriesResponse,
} from "./types"
/** 获取模板列表 */
/** 获取模板列表
*
* valid_only=true 时请求后端仅返回已配置片段的模板(剪辑页选模板使用,
* 避免选中无片段配置的模板导致 from-assets 400#1769/#1772);
* 后端尚未支持该参数时会忽略未知 query 字段,前端再按 segments/is_active 兜底过滤。
* 模板编辑器/我的模板不传,可查看全部模板(含未配置片段的草稿)。
*/
export const getEditingTemplates = async (params?: {
category?: string
tag?: string
skip?: number
limit?: number
validOnly?: boolean
}): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
...(params?.validOnly ? { valid_only: true } : {}),
},
})
let list = response.data.items
+7 -2
View File
@@ -91,7 +91,7 @@ export async function createClipsFromAssets(
assetIds: string[],
clipType = "main",
requiredClipsCount?: number,
opts?: { signal?: AbortSignal },
opts?: { signal?: AbortSignal; silentErrorToast?: boolean },
): Promise<ClipsFromAssetsResponse> {
const body: Record<string, unknown> = {
asset_ids: assetIds,
@@ -104,7 +104,12 @@ export async function createClipsFromAssets(
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
body,
{ timeout: 60000, signal: opts?.signal },
{
timeout: 60000,
signal: opts?.signal,
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
},
)
return response.data
}
@@ -43,11 +43,17 @@ export async function updateEditPlanClips(
templateId: string,
clips: EditPlanClipInput[],
signal?: AbortSignal,
/** 为 true 时抑制全局错误 toast(调用方自行提示,如失效模板回退 #1777) */
silentErrorToast?: boolean,
): Promise<{ count: number }> {
const response = await apiClient.put(
`/templates/${templateId}/editor/clips`,
{ clips },
{ signal },
{
signal,
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast)
...(silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
},
)
return response.data
}
+13
View File
@@ -18,6 +18,7 @@ import {
ControlOutlined,
CrownOutlined,
UnorderedListOutlined,
UserOutlined,
} from "@ant-design/icons"
/** 导航项类型 */
@@ -88,6 +89,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/generate",
icon: React.createElement(VideoCameraOutlined),
},
{
key: "ai-avatar",
label: "AI数字人",
path: "/app/ai-avatar",
icon: React.createElement(UserOutlined),
},
{
key: "history",
label: "任务历史",
@@ -131,6 +138,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/generate",
icon: React.createElement(VideoCameraOutlined),
},
{
key: "ai-avatar",
label: "AI数字人",
path: "/app/ai-avatar",
icon: React.createElement(UserOutlined),
},
{
key: "editing-planner",
label: "剪辑模板",
+607
View File
@@ -0,0 +1,607 @@
/**
* AI数字人页面 — 5 列水平面板布局 (#1798)
*/
/* ── 页面容器 ── */
.ai-avatar-page {
display: flex;
gap: 0;
height: 100%;
min-width: 1280px;
overflow-x: auto;
background: var(--bg-primary, #0f0f0f);
}
/* ── 面板通用 ── */
.ai-avatar-panel {
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color, #2a2a2a);
overflow: hidden;
transition: width 0.2s ease;
}
.ai-avatar-panel:last-child {
border-right: none;
}
.ai-avatar-panel.collapsed {
width: 48px !important;
min-width: 48px !important;
}
.ai-avatar-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-secondary, #1a1a1a);
border-bottom: 1px solid var(--border-color, #2a2a2a);
cursor: pointer;
user-select: none;
flex-shrink: 0;
}
.ai-avatar-panel-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ai-avatar-panel-header .collapse-btn {
background: none;
border: none;
color: var(--text-secondary, #999);
cursor: pointer;
padding: 2px;
font-size: 12px;
transition: transform 0.2s;
}
.ai-avatar-panel.collapsed .collapse-btn {
transform: rotate(180deg);
}
.ai-avatar-panel-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.ai-avatar-panel.collapsed .ai-avatar-panel-body {
display: none;
}
.ai-avatar-panel.collapsed .ai-avatar-panel-header h3 {
display: none;
}
/* ── 面板宽度 ── */
.ai-avatar-panel.panel-avatar-video {
width: 20%;
}
.ai-avatar-panel.panel-voice-clone {
width: 20%;
}
.ai-avatar-panel.panel-script-lipsync {
width: 25%;
}
.ai-avatar-panel.panel-title-config {
width: 17.5%;
}
.ai-avatar-panel.panel-cover-generate {
width: 17.5%;
}
/* ── 上传拖拽区 ── */
.ai-avatar-upload-zone {
border: 2px dashed var(--border-color, #2a2a2a);
border-radius: 8px;
padding: 32px 16px;
text-align: center;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
color: var(--text-secondary, #999);
font-size: 13px;
}
.ai-avatar-upload-zone:hover {
border-color: var(--primary-color, #3b82f6);
background: rgba(59, 130, 246, 0.05);
}
.ai-avatar-upload-zone .upload-icon {
font-size: 32px;
margin-bottom: 8px;
display: block;
}
/* ── 视频/音频预览 ── */
.ai-avatar-media-preview {
width: 100%;
border-radius: 8px;
overflow: hidden;
background: #000;
margin-top: 12px;
}
.ai-avatar-media-preview video,
.ai-avatar-media-preview audio {
width: 100%;
display: block;
}
.ai-avatar-media-info {
display: flex;
gap: 12px;
padding: 8px 0;
font-size: 12px;
color: var(--text-secondary, #999);
}
/* ── 文案输入 ── */
.ai-avatar-script-editor {
width: 100%;
min-height: 120px;
resize: vertical;
background: var(--bg-tertiary, #222);
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 6px;
padding: 12px;
color: var(--text-primary, #fff);
font-size: 14px;
line-height: 1.6;
font-family: inherit;
}
.ai-avatar-script-editor:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
}
.ai-avatar-script-tabs {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.ai-avatar-script-tabs button {
padding: 6px 12px;
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #999);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.ai-avatar-script-tabs button.active {
background: var(--primary-color, #3b82f6);
color: #fff;
border-color: var(--primary-color, #3b82f6);
}
.ai-avatar-script-word-count {
text-align: right;
font-size: 12px;
color: var(--text-secondary, #999);
margin-top: 4px;
}
/* ── 对口型预览 ── */
.ai-avatar-lipsync-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color, #2a2a2a);
}
.ai-avatar-lipsync-section h4 {
font-size: 13px;
color: var(--text-secondary, #999);
margin: 0 0 12px;
font-weight: 500;
}
.ai-avatar-lipsync-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
/* ── 声音克隆状态 ── */
.ai-avatar-clone-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
margin-top: 12px;
}
.ai-avatar-clone-status.success {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.ai-avatar-clone-status.cloning {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.ai-avatar-clone-status.failed {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
/* ── 音色列表 ── */
.ai-avatar-voice-list {
margin-top: 16px;
}
.ai-avatar-voice-list h4 {
font-size: 13px;
color: var(--text-secondary, #999);
margin: 0 0 8px;
font-weight: 500;
}
.ai-avatar-voice-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.ai-avatar-voice-item:hover {
background: var(--bg-tertiary, #222);
}
.ai-avatar-voice-item.selected {
background: rgba(59, 130, 246, 0.1);
border: 1px solid var(--primary-color, #3b82f6);
}
/* ── 字幕设置 ── */
.ai-avatar-subtitle-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color, #2a2a2a);
}
.ai-avatar-subtitle-section label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary, #999);
cursor: pointer;
}
/* ── 封面预览 ── */
.ai-avatar-cover-preview {
width: 100%;
aspect-ratio: 16/9;
border-radius: 8px;
overflow: hidden;
background: var(--bg-tertiary, #222);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary, #999);
font-size: 13px;
margin-bottom: 12px;
}
.ai-avatar-cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── 生成设置 ── */
.ai-avatar-generate-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color, #2a2a2a);
}
.ai-avatar-generate-section .field-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 13px;
color: var(--text-primary, #fff);
}
.ai-avatar-generate-section select {
background: var(--bg-tertiary, #222);
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 4px;
color: var(--text-primary, #fff);
padding: 4px 8px;
font-size: 13px;
}
/* ── 生成按钮 ── */
.ai-avatar-generate-btn {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
margin-top: 16px;
}
.ai-avatar-generate-btn:hover {
opacity: 0.9;
}
.ai-avatar-generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── 弹窗通用 ── */
.ai-avatar-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.ai-avatar-modal {
background: var(--bg-secondary, #1a1a1a);
border-radius: 12px;
width: 640px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.ai-avatar-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #2a2a2a);
}
.ai-avatar-modal-header h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary, #fff);
}
.ai-avatar-modal-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.ai-avatar-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-color, #2a2a2a);
}
/* ── 文案选择弹窗 ── */
.ai-avatar-script-search {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.ai-avatar-script-search input {
flex: 1;
background: var(--bg-tertiary, #222);
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary, #fff);
font-size: 14px;
}
.ai-avatar-script-search input:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
}
.ai-avatar-script-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color, #2a2a2a);
cursor: pointer;
transition: background 0.2s;
}
.ai-avatar-script-item:hover {
background: var(--bg-tertiary, #222);
}
.ai-avatar-script-item.selected {
background: rgba(59, 130, 246, 0.1);
}
.ai-avatar-script-item-info {
flex: 1;
}
.ai-avatar-script-item-info h4 {
margin: 0 0 4px;
font-size: 14px;
color: var(--text-primary, #fff);
font-weight: 500;
}
.ai-avatar-script-item-info span {
font-size: 12px;
color: var(--text-secondary, #999);
}
/* ── B-roll 弹窗 ── */
.ai-avatar-broll-layout {
display: flex;
gap: 16px;
}
.ai-avatar-broll-timeline {
flex: 1;
min-width: 0;
}
.ai-avatar-broll-settings {
width: 220px;
flex-shrink: 0;
}
.ai-avatar-broll-thumbnails {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.ai-avatar-broll-thumb {
aspect-ratio: 16/9;
border-radius: 4px;
overflow: hidden;
background: var(--bg-tertiary, #222);
cursor: grab;
border: 2px solid transparent;
transition: border-color 0.2s;
}
.ai-avatar-broll-thumb:hover {
border-color: var(--primary-color, #3b82f6);
}
.ai-avatar-broll-thumb.selected {
border-color: var(--primary-color, #3b82f6);
}
.ai-avatar-pip-positions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
width: 120px;
}
.ai-avatar-pip-positions button {
padding: 4px 8px;
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #999);
font-size: 11px;
cursor: pointer;
}
.ai-avatar-pip-positions button.active {
background: var(--primary-color, #3b82f6);
color: #fff;
border-color: var(--primary-color, #3b82f6);
}
/* ── 状态指示器 ── */
.ai-avatar-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
}
.ai-avatar-status-badge.completed {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.ai-avatar-status-badge.processing {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.ai-avatar-status-badge.failed {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
/* ── 通用按钮 ── */
.aa-btn {
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1px solid var(--border-color, #2a2a2a);
background: transparent;
color: var(--text-primary, #fff);
transition: all 0.2s;
}
.aa-btn:hover {
background: var(--bg-tertiary, #222);
}
.aa-btn-primary {
background: var(--primary-color, #3b82f6);
color: #fff;
border-color: var(--primary-color, #3b82f6);
}
.aa-btn-primary:hover {
opacity: 0.9;
background: var(--primary-color, #3b82f6);
}
.aa-btn-sm {
padding: 4px 10px;
font-size: 12px;
}
/* ── 单选组 ── */
.aa-radio-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.aa-radio-group label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary, #fff);
cursor: pointer;
}
/* ── 分割线 ── */
.aa-divider {
border: none;
border-top: 1px solid var(--border-color, #2a2a2a);
margin: 16px 0;
}
@@ -0,0 +1,102 @@
/**
* AI数字人 — 主页面(5列水平面板布局)(#1798)
*/
import React from "react"
import { useAiAvatarState } from "./hooks/useAiAvatarState"
import AvatarVideoPanel from "./components/AvatarVideoPanel"
import VoiceClonePanel from "./components/VoiceClonePanel"
import ScriptLipsyncPanel from "./components/ScriptLipsyncPanel"
import TitleConfigPanel from "./components/TitleConfigPanel"
import CoverGeneratePanel from "./components/CoverGeneratePanel"
import ScriptSelectModal from "./components/ScriptSelectModal"
import BRollInsertModal from "./components/BRollInsertModal"
import "./AiAvatar.css"
const AiAvatarPage: React.FC = () => {
const state = useAiAvatarState()
const handleSubmitGenerate = React.useCallback(() => {
// TODO: 调用 submitRender API
state.setIsGenerating(true)
}, [state])
return (
<div className="ai-avatar-page">
{/* 面板1:出镜视频 */}
<AvatarVideoPanel
video={state.avatarVideo}
onVideoChange={state.setAvatarVideo}
collapsed={!!state.collapsedPanels["avatar-video"]}
onToggleCollapse={() => state.togglePanel("avatar-video")}
/>
{/* 面板2:声音克隆 */}
<VoiceClonePanel
voiceClone={state.voiceClone}
setVoiceClone={state.setVoiceClone}
selectedVoiceId={state.selectedVoiceId}
setSelectedVoiceId={state.setSelectedVoiceId}
collapsed={!!state.collapsedPanels["voice-clone"]}
onToggleCollapse={() => state.togglePanel("voice-clone")}
/>
{/* 面板3:文案 & 对口型 */}
<ScriptLipsyncPanel
selectedScript={state.selectedScript}
setSelectedScript={state.setSelectedScript}
scriptContent={state.scriptContent}
setScriptContent={state.setScriptContent}
lipsyncJob={state.lipsyncJob}
setLipsyncJob={state.setLipsyncJob}
onOpenScriptModal={() => state.setScriptModalOpen(true)}
onOpenBRollModal={() => state.setBrollModalOpen(true)}
collapsed={!!state.collapsedPanels["script-lipsync"]}
onToggleCollapse={() => state.togglePanel("script-lipsync")}
/>
{/* 面板4:标题配置 */}
<TitleConfigPanel
titleConfig={state.titleConfig}
setTitleConfig={state.setTitleConfig}
collapsed={!!state.collapsedPanels["title-config"]}
onToggleCollapse={() => state.togglePanel("title-config")}
/>
{/* 面板5:封面 & 生成 */}
<CoverGeneratePanel
coverConfig={state.coverConfig}
setCoverConfig={state.setCoverConfig}
generateConfig={state.generateConfig}
setGenerateConfig={state.setGenerateConfig}
isGenerating={state.isGenerating}
onSubmitGenerate={handleSubmitGenerate}
collapsed={!!state.collapsedPanels["cover-generate"]}
onToggleCollapse={() => state.togglePanel("cover-generate")}
/>
{/* 弹窗:文案选择 */}
<ScriptSelectModal
open={state.scriptModalOpen}
onClose={() => state.setScriptModalOpen(false)}
onSelect={(script) => {
state.setSelectedScript(script)
state.setScriptContent(script.content)
state.setScriptModalOpen(false)
}}
/>
{/* 弹窗:B-roll 插入 */}
<BRollInsertModal
open={state.brollModalOpen}
onClose={() => state.setBrollModalOpen(false)}
onConfirm={(segment) => {
state.setBRollSegments((prev) => [...prev, segment])
state.setBrollModalOpen(false)
}}
videoDuration={state.lipsyncJob?.output_duration ?? 0}
/>
</div>
)
}
export default AiAvatarPage
@@ -0,0 +1,75 @@
/**
* AI数字人 API 调用封装 (#1798)
*/
import apiClient from "@/api/client"
import type {
Script,
LipsyncJob,
AiAvatarRenderRequest,
AiAvatarRenderJob,
} from "../types/aiAvatar"
/* ── 文案库 ── */
export async function getScripts(params?: { search?: string; offset?: number; limit?: number }) {
const { data } = await apiClient.get<{ items: Script[]; total: number }>("/scripts", { params })
return data
}
export async function getScript(id: string) {
const { data } = await apiClient.get<Script>(`/scripts/${id}`)
return data
}
export async function createScript(payload: { title: string; content: string; tags?: string[] }) {
const { data } = await apiClient.post<Script>("/scripts", payload)
return data
}
export async function deleteScript(id: string) {
await apiClient.delete(`/scripts/${id}`)
}
/* ── 对口型 ── */
export async function createLipsyncJob(payload: {
video_url: string
audio_url: string
enable_video_loop?: boolean
project_id?: string
}) {
const { data } = await apiClient.post<LipsyncJob>("/lipsync/jobs", payload)
return data
}
export async function getLipsyncJob(id: string) {
const { data } = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`)
return data
}
/* ── 渲染合成 ── */
export async function submitRender(payload: AiAvatarRenderRequest) {
const { data } = await apiClient.post<AiAvatarRenderJob>("/ai-avatar/render", payload)
return data
}
export async function getRenderJobs(params?: { project_id?: string; status?: string }) {
const { data } = await apiClient.get<AiAvatarRenderJob[]>("/ai-avatar/render/jobs", { params })
return data
}
export async function getRenderJob(jobId: string) {
const { data } = await apiClient.get<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}`)
return data
}
export async function cancelRenderJob(jobId: string) {
const { data } = await apiClient.post<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}/cancel`)
return data
}
export async function retryRenderJob(jobId: string) {
const { data } = await apiClient.post<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}/retry`)
return data
}
/* ── 素材上传(复用已有 API) ── */
export { prepareDirectUpload, completeDirectUpload } from "@/api/assets"
@@ -0,0 +1,226 @@
import { useState, useRef, useCallback } from "react"
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { AvatarVideo } from "../types/aiAvatar"
interface AvatarVideoPanelProps {
video: AvatarVideo | null
onVideoChange: (v: AvatarVideo | null) => void
collapsed: boolean
onToggleCollapse: () => void
}
const MAX_VIDEO_SIZE = 500 * 1024 * 1024 // 500MB
/** 格式化秒数为 mm:ss */
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}
/** 获取视频元信息 */
function getVideoMetadata(
file: File,
): Promise<{ duration: number; width: number; height: number }> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const video = document.createElement("video")
video.preload = "metadata"
video.onloadedmetadata = () => {
resolve({
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight,
})
URL.revokeObjectURL(url)
}
video.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error("无法读取视频信息"))
}
video.src = url
})
}
const AvatarVideoPanel: React.FC<AvatarVideoPanelProps> = ({
video,
onVideoChange,
collapsed,
onToggleCollapse,
}) => {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUpload = useCallback(
async (file: File) => {
if (file.size > MAX_VIDEO_SIZE) {
alert("视频文件大小不能超过 500MB")
return
}
if (!file.type.startsWith("video/")) {
alert("请上传 MP4 格式的视频文件")
return
}
try {
// 获取视频元信息
const metadata = await getVideoMetadata(file)
setUploading(true)
setUploadProgress(0)
// 获取或创建默认项目
const project = await getOrCreateDefaultProject()
// 获取或创建默认视频库
const library = await ensureDefaultLibrary({
project_id: project.id,
kind: "video",
})
// 上传文件到 OSS
await uploadAssetDirect({
file,
library_id: library.id,
onProgress: (p) => setUploadProgress(p),
})
// 构建视频对象(使用本地预览 URL)
const previewUrl = URL.createObjectURL(file)
onVideoChange({
url: previewUrl,
name: file.name,
duration: metadata.duration,
width: metadata.width,
height: metadata.height,
size: file.size,
})
} catch (err) {
const message = err instanceof Error ? err.message : "上传失败,请重试"
alert(message)
} finally {
setUploading(false)
setUploadProgress(0)
}
},
[onVideoChange],
)
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
}
// 清空 input 以支持重复选择同一文件
e.target.value = ""
},
[handleUpload],
)
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) {
handleUpload(file)
}
},
[handleUpload],
)
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragOver(true)
}, [])
const handleDragLeave = useCallback(() => {
setDragOver(false)
}, [])
const handleRemove = useCallback(() => {
onVideoChange(null)
}, [onVideoChange])
return (
<div className={`ai-avatar-panel panel-avatar-video ${collapsed ? "collapsed" : ""}`}>
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
<h3></h3>
<button className="collapse-btn"></button>
</div>
<div className="ai-avatar-panel-body">
{video ? (
<div>
<div className="ai-avatar-media-preview">
<video src={video.url} controls />
</div>
<div className="ai-avatar-media-info">
<span>{formatDuration(video.duration)}</span>
<span>
{video.width}×{video.height}
</span>
</div>
<div className="ai-avatar-media-info">
<span>{video.name}</span>
</div>
<button className="aa-btn aa-btn-sm" onClick={handleRemove} style={{ marginTop: 8 }}>
</button>
</div>
) : (
<div
className={`ai-avatar-upload-zone ${dragOver ? "drag-over" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<span className="upload-icon">🎬</span>
{uploading ? (
<div>
<div>... {uploadProgress}%</div>
<div
style={{
width: "100%",
height: 4,
background: "#2a2a2a",
borderRadius: 2,
marginTop: 8,
}}
>
<div
style={{
width: `${uploadProgress}%`,
height: "100%",
background: "#3b82f6",
borderRadius: 2,
transition: "width 0.2s",
}}
/>
</div>
</div>
) : (
<div>
<div></div>
<div style={{ marginTop: 4, fontSize: 12 }}>MP4 500MB</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="video/mp4,video/*"
onChange={handleFileSelect}
style={{ display: "none" }}
/>
</div>
)}
</div>
</div>
)
}
export default AvatarVideoPanel
@@ -0,0 +1,272 @@
/**
* B-roll 画面插入弹窗
* 左右布局:左侧素材缩略图 + 右侧设置
*/
import React, { useCallback, useState } from "react"
import type { BRollSegment } from "../types/aiAvatar"
interface BRollInsertModalProps {
open: boolean
onClose: () => void
onConfirm: (segment: BRollSegment) => void
videoDuration: number
}
/** 画中画位置选项 */
const PIP_POSITIONS = [
{ value: "top-left", label: "左上" },
{ value: "top-right", label: "右上" },
{ value: "bottom-left", label: "左下" },
{ value: "bottom-right", label: "右下" },
]
/** 格式化秒数为 mm:ss */
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`
}
const BRollInsertModal: React.FC<BRollInsertModalProps> = ({
open,
onClose,
onConfirm,
videoDuration,
}) => {
/* 素材列表(示例数据,实际使用时通过 props 或 API 传入) */
const [assets, setAssets] = useState<{ id: string; url: string; name: string }[]>([])
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null)
/* 设置 */
const [insertMode, setInsertMode] = useState<"fullscreen" | "pip">("fullscreen")
const [pipPosition, setPipPosition] = useState("top-right")
const [startTime, setStartTime] = useState(0)
const [endTime, setEndTime] = useState(5)
const [scriptIndex, setScriptIndex] = useState(0)
const handleConfirm = useCallback(() => {
const asset = assets.find((a) => a.id === selectedAssetId)
if (!asset) return
const segment: BRollSegment = {
script_segment_index: scriptIndex,
asset_url: asset.url,
mode: insertMode,
start_time: startTime,
end_time: endTime,
pip_position: insertMode === "pip" ? pipPosition : undefined,
pip_scale: insertMode === "pip" ? 0.3 : undefined,
}
onConfirm(segment)
onClose()
}, [
assets,
selectedAssetId,
scriptIndex,
insertMode,
pipPosition,
startTime,
endTime,
onConfirm,
onClose,
])
/* 上传新素材占位 */
const handleUploadAsset = useCallback(() => {
/* 实际项目中触发文件上传逻辑 */
const newAsset = {
id: `asset-${Date.now()}`,
url: "",
name: "新素材",
}
setAssets((prev) => [...prev, newAsset])
setSelectedAssetId(newAsset.id)
}, [])
if (!open) return null
return (
<div className="ai-avatar-modal-overlay" onClick={onClose}>
<div className="ai-avatar-modal" style={{ width: 800 }} onClick={(e) => e.stopPropagation()}>
<div className="ai-avatar-modal-header">
<h3> B-roll </h3>
<button className="aa-btn aa-btn-sm" onClick={onClose}>
</button>
</div>
<div className="ai-avatar-modal-body">
<div className="ai-avatar-broll-layout">
{/* 左侧:素材缩略图 */}
<div className="ai-avatar-broll-timeline">
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
</h4>
<div className="ai-avatar-broll-thumbnails">
{assets.map((asset) => (
<div
key={asset.id}
className={`ai-avatar-broll-thumb ${selectedAssetId === asset.id ? "selected" : ""}`}
onClick={() => setSelectedAssetId(asset.id)}
title={asset.name}
>
{asset.url ? (
<img
src={asset.url}
alt={asset.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
fontSize: 11,
color: "#666",
}}
>
{asset.name}
</div>
)}
</div>
))}
</div>
<button className="aa-btn" onClick={handleUploadAsset}>
+
</button>
</div>
{/* 右侧:设置 */}
<div className="ai-avatar-broll-settings">
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
</h4>
{/* 插入位置 */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
</label>
<input
type="number"
min={0}
value={scriptIndex}
onChange={(e) => setScriptIndex(Number(e.target.value))}
style={{
width: "100%",
background: "#222",
border: "1px solid #2a2a2a",
borderRadius: 4,
padding: "6px 8px",
color: "#fff",
fontSize: 13,
}}
/>
</div>
{/* 开始时间 */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
{formatTime(startTime)}
</label>
<input
type="range"
min={0}
max={videoDuration}
step={0.1}
value={startTime}
onChange={(e) => setStartTime(Number(e.target.value))}
style={{ width: "100%" }}
/>
</div>
{/* 持续时间 */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
{formatTime(endTime)}
</label>
<input
type="range"
min={startTime}
max={videoDuration}
step={0.1}
value={endTime}
onChange={(e) => setEndTime(Number(e.target.value))}
style={{ width: "100%" }}
/>
</div>
<hr className="aa-divider" />
{/* 插入模式 */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 8 }}>
</label>
<div className="aa-radio-group">
<label>
<input
type="radio"
name="brollInsertMode"
value="fullscreen"
checked={insertMode === "fullscreen"}
onChange={() => setInsertMode("fullscreen")}
/>
</label>
<label>
<input
type="radio"
name="brollInsertMode"
value="pip"
checked={insertMode === "pip"}
onChange={() => setInsertMode("pip")}
/>
</label>
</div>
</div>
{/* 画中画位置选择 */}
{insertMode === "pip" && (
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 8 }}>
</label>
<div className="ai-avatar-pip-positions">
{PIP_POSITIONS.map((pos) => (
<button
key={pos.value}
className={pipPosition === pos.value ? "active" : ""}
onClick={() => setPipPosition(pos.value)}
>
{pos.label}
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
<div className="ai-avatar-modal-footer">
<button className="aa-btn" onClick={onClose}>
</button>
<button
className="aa-btn aa-btn-primary"
disabled={!selectedAssetId}
onClick={handleConfirm}
>
</button>
</div>
</div>
</div>
)
}
export default BRollInsertModal
@@ -0,0 +1,131 @@
/**
* 面板5:封面 & 生成
* 封面预览 + 生成设置 + 生成按钮
*/
import React, { useCallback } from "react"
import type { AiAvatarCoverConfig, AiAvatarGenerateConfig } from "../types/aiAvatar"
interface CoverGeneratePanelProps {
coverConfig: AiAvatarCoverConfig
setCoverConfig: (c: AiAvatarCoverConfig) => void
generateConfig: AiAvatarGenerateConfig
setGenerateConfig: (c: AiAvatarGenerateConfig) => void
isGenerating: boolean
onSubmitGenerate: () => void
collapsed: boolean
onToggleCollapse: () => void
}
const CoverGeneratePanel: React.FC<CoverGeneratePanelProps> = ({
coverConfig,
setCoverConfig,
generateConfig,
setGenerateConfig,
isGenerating,
onSubmitGenerate,
collapsed,
onToggleCollapse,
}) => {
const handleFrameCapture = useCallback(() => {
setCoverConfig({ ...coverConfig, mode: "frame", enabled: true })
}, [coverConfig, setCoverConfig])
const handleCustomUpload = useCallback(() => {
setCoverConfig({ ...coverConfig, mode: "upload", enabled: true })
}, [coverConfig, setCoverConfig])
return (
<div className={`ai-avatar-panel panel-cover-generate ${collapsed ? "collapsed" : ""}`}>
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
<h3> &amp; </h3>
<button className="collapse-btn"></button>
</div>
<div className="ai-avatar-panel-body">
{/* 封面预览区 */}
<div className="ai-avatar-cover-preview">
{coverConfig.thumbnail_url ? (
<img src={coverConfig.thumbnail_url} alt="封面预览" />
) : (
<span></span>
)}
</div>
{/* 封面操作按钮 */}
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<button className="aa-btn" style={{ flex: 1 }} onClick={handleFrameCapture}>
</button>
<button className="aa-btn" style={{ flex: 1 }} onClick={handleCustomUpload}>
</button>
</div>
{/* 分割线 + 生成设置 */}
<hr className="aa-divider" />
<div className="ai-avatar-generate-section">
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
</h4>
{/* 分辨率 */}
<div className="field-row">
<span></span>
<select
value={generateConfig.resolution}
onChange={(e) =>
setGenerateConfig({
...generateConfig,
resolution: e.target.value as "720p" | "1080p",
})
}
>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
</select>
</div>
{/* 画面插入模式 */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 13, color: "#fff", marginBottom: 8 }}>
</label>
<div className="aa-radio-group">
<label>
<input
type="radio"
name="bRollMode"
value="fullscreen"
checked={generateConfig.bRollMode === "fullscreen"}
onChange={() => setGenerateConfig({ ...generateConfig, bRollMode: "fullscreen" })}
/>
</label>
<label>
<input
type="radio"
name="bRollMode"
value="pip"
checked={generateConfig.bRollMode === "pip"}
onChange={() => setGenerateConfig({ ...generateConfig, bRollMode: "pip" })}
/>
</label>
</div>
</div>
</div>
{/* 生成按钮 */}
<button
className="ai-avatar-generate-btn"
disabled={isGenerating}
onClick={onSubmitGenerate}
>
{isGenerating ? "生成中..." : "🚀 开始生成视频"}
</button>
</div>
</div>
)
}
export default CoverGeneratePanel
@@ -0,0 +1,189 @@
import { useState, useCallback } from "react"
import type { Script, LipsyncJob } from "../types/aiAvatar"
interface ScriptLipsyncPanelProps {
selectedScript: Script | null
setSelectedScript: (s: Script | null) => void
scriptContent: string
setScriptContent: (content: string) => void
lipsyncJob: LipsyncJob | null
setLipsyncJob: (job: LipsyncJob | null) => void
onOpenScriptModal: () => void
onOpenBRollModal: () => void
collapsed: boolean
onToggleCollapse: () => void
}
type ScriptTab = "library" | "manual"
/** 对口型状态标签 */
const LIPSYNC_STATUS_LABEL: Record<string, string> = {
pending: "等待中",
processing: "处理中",
completed: "已完成",
failed: "失败",
}
const LIPSYNC_STATUS_CLASS: Record<string, string> = {
pending: "processing",
processing: "processing",
completed: "completed",
failed: "failed",
}
const ScriptLipsyncPanel: React.FC<ScriptLipsyncPanelProps> = ({
selectedScript,
setSelectedScript,
scriptContent,
setScriptContent,
lipsyncJob,
setLipsyncJob,
onOpenScriptModal,
onOpenBRollModal,
collapsed,
onToggleCollapse,
}) => {
const [activeTab, setActiveTab] = useState<ScriptTab>("manual")
const handleTabChange = useCallback(
(tab: ScriptTab) => {
setActiveTab(tab)
if (tab === "library") {
onOpenScriptModal()
}
},
[onOpenScriptModal],
)
const handleScriptChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setScriptContent(e.target.value)
// 清除已选脚本(用户手动输入时)
if (selectedScript) {
setSelectedScript(null)
}
},
[setScriptContent, selectedScript, setSelectedScript],
)
const handleRegenerateLipsync = useCallback(() => {
// TODO: 调用实际的对口型 API
if (!scriptContent) {
alert("请先输入文案内容")
return
}
// 模拟创建对口型任务
const newJob: LipsyncJob = {
id: `lipsync-${Date.now()}`,
status: "pending",
video_url: "",
audio_url: "",
output_video_url: "",
output_duration: 0,
error_message: "",
submitted_at: new Date().toISOString(),
}
setLipsyncJob(newJob)
// 模拟处理流程
setTimeout(() => {
setLipsyncJob({ ...newJob, status: "processing" })
}, 1000)
setTimeout(() => {
setLipsyncJob({
...newJob,
status: "completed",
output_video_url: "",
output_duration: 30,
completed_at: new Date().toISOString(),
})
}, 5000)
}, [scriptContent, setLipsyncJob])
return (
<div className={`ai-avatar-panel panel-script-lipsync ${collapsed ? "collapsed" : ""}`}>
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
<h3> & </h3>
<button className="collapse-btn"></button>
</div>
<div className="ai-avatar-panel-body">
{/* 上半区:文案编辑 */}
<div className="ai-avatar-script-tabs">
<button
className={activeTab === "library" ? "active" : ""}
onClick={() => handleTabChange("library")}
>
</button>
<button
className={activeTab === "manual" ? "active" : ""}
onClick={() => handleTabChange("manual")}
>
</button>
</div>
{selectedScript && activeTab === "library" && (
<div style={{ marginBottom: 8, fontSize: 13, color: "#999" }}>
{selectedScript.title}
</div>
)}
<textarea
className="ai-avatar-script-editor"
placeholder="请输入视频文案内容..."
value={scriptContent}
onChange={handleScriptChange}
disabled={activeTab === "library"}
/>
<div className="ai-avatar-script-word-count">{scriptContent.length} </div>
{/* 下半区:对口型预览 */}
<div className="ai-avatar-lipsync-section">
<h4></h4>
{/* 视频预览区域 */}
{lipsyncJob?.output_video_url && (
<div className="ai-avatar-media-preview">
<video src={lipsyncJob.output_video_url} controls />
</div>
)}
{/* 状态标签 */}
{lipsyncJob && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 8,
}}
>
<span
className={`ai-avatar-status-badge ${LIPSYNC_STATUS_CLASS[lipsyncJob.status] || ""}`}
>
{LIPSYNC_STATUS_LABEL[lipsyncJob.status] || lipsyncJob.status}
</span>
{lipsyncJob.error_message && (
<span style={{ fontSize: 12, color: "#ef4444" }}>{lipsyncJob.error_message}</span>
)}
</div>
)}
{/* 操作按钮 */}
<div className="ai-avatar-lipsync-actions">
<button className="aa-btn aa-btn-sm" onClick={onOpenBRollModal}>
🎬
</button>
<button className="aa-btn aa-btn-sm aa-btn-primary" onClick={handleRegenerateLipsync}>
</button>
</div>
</div>
</div>
</div>
)
}
export default ScriptLipsyncPanel
@@ -0,0 +1,122 @@
/**
* 文案选择弹窗
* 搜索 + 文案列表 + 选择回调
*/
import React, { useCallback, useEffect, useState } from "react"
import type { Script } from "../types/aiAvatar"
import { getScripts } from "../api/aiAvatar"
interface ScriptSelectModalProps {
open: boolean
onClose: () => void
onSelect: (script: Script) => void
}
const ScriptSelectModal: React.FC<ScriptSelectModalProps> = ({ open, onClose, onSelect }) => {
const [searchText, setSearchText] = useState("")
const [scripts, setScripts] = useState<Script[]>([])
const [loading, setLoading] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
/* 加载文案列表 */
const fetchScripts = useCallback(async () => {
setLoading(true)
try {
const result = await getScripts({ search: searchText || undefined })
setScripts(result.items)
} catch {
setScripts([])
} finally {
setLoading(false)
}
}, [searchText])
useEffect(() => {
if (open) {
setSearchText("")
setSelectedId(null)
fetchScripts()
}
}, [open, fetchScripts])
/* 搜索防抖 */
useEffect(() => {
if (!open) return
const timer = setTimeout(() => {
fetchScripts()
}, 300)
return () => clearTimeout(timer)
}, [searchText, open, fetchScripts])
const handleSelect = useCallback(
(script: Script) => {
setSelectedId(script.id)
onSelect(script)
onClose()
},
[onSelect, onClose],
)
/* 格式化时间 */
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
}
if (!open) return null
return (
<div className="ai-avatar-modal-overlay" onClick={onClose}>
<div className="ai-avatar-modal" onClick={(e) => e.stopPropagation()}>
<div className="ai-avatar-modal-header">
<h3></h3>
<button className="aa-btn aa-btn-sm" onClick={onClose}>
</button>
</div>
<div className="ai-avatar-modal-body">
{/* 搜索栏 + 新建文案 */}
<div className="ai-avatar-script-search">
<input
type="text"
placeholder="搜索文案标题..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<button className="aa-btn aa-btn-primary"></button>
</div>
{/* 文案列表 */}
{loading ? (
<div style={{ textAlign: "center", padding: 32, color: "#999", fontSize: 13 }}>
...
</div>
) : scripts.length === 0 ? (
<div style={{ textAlign: "center", padding: 32, color: "#999", fontSize: 13 }}>
</div>
) : (
scripts.map((script) => (
<div
key={script.id}
className={`ai-avatar-script-item ${selectedId === script.id ? "selected" : ""}`}
onClick={() => handleSelect(script)}
>
<div className="ai-avatar-script-item-info">
<h4>{script.title}</h4>
<span>
{script.content.length} · {formatDate(script.created_at)}
</span>
</div>
<button className="aa-btn aa-btn-sm aa-btn-primary"></button>
</div>
))
)}
</div>
</div>
</div>
)
}
export default ScriptSelectModal
@@ -0,0 +1,250 @@
/**
* 面板4:标题配置
* 主标题输入 + 复用 TitleStylePanel + 字幕设置
*/
import React, { useCallback, useMemo, useState } from "react"
import type { AiAvatarTitleConfig } from "../types/aiAvatar"
import type { TitleSettings, TitlePreset } from "@/pages/generate/types"
import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
interface TitleConfigPanelProps {
titleConfig: AiAvatarTitleConfig
setTitleConfig: (c: AiAvatarTitleConfig) => void
collapsed: boolean
onToggleCollapse: () => void
}
/* ── 位置选项 ── */
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "top-left", label: "左上" },
{ value: "top-right", label: "右上" },
{ value: "bottom-left", label: "左下" },
{ value: "bottom-right", label: "右下" },
{ value: "custom", label: "自由位置" },
]
/* ── 字体选项 ── */
const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"阿里巴巴普惠体",
"站酷高端黑",
"站酷快乐体",
"方正兰亭黑",
"方正楷体",
"汉仪旗黑",
]
/* ── 标题预设 ── */
const titlePresets: TitlePreset[] = [
{
key: "default",
label: "默认",
style: { size: 36, color: "#ffffff", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: { fontSize: 16, color: "#ffffff", fontWeight: 400 },
},
{
key: "bold-white",
label: "粗体白",
style: { size: 48, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: { fontSize: 18, color: "#ffffff", fontWeight: 700 },
},
{
key: "highlight-yellow",
label: "高亮黄",
style: { size: 44, color: "#FFD700", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: { fontSize: 17, color: "#FFD700", fontWeight: 700 },
},
{
key: "elegant-serif",
label: "优雅宋体",
style: { size: 40, color: "#f0f0f0", bold: false, italic: true, stroke: false, shadow: true },
previewStyle: { fontSize: 16, color: "#f0f0f0", fontStyle: "italic", fontFamily: "serif" },
},
{
key: "impact",
label: "冲击力",
style: { size: 56, color: "#ff4444", bold: true, italic: false, stroke: true, shadow: true },
previewStyle: { fontSize: 20, color: "#ff4444", fontWeight: 900 },
},
]
/** 将 AiAvatarTitleConfig 适配为 TitleSettings */
function toTitleSettings(config: AiAvatarTitleConfig): TitleSettings {
return {
aiAutoSelect: false,
title: config.title,
position: config.position,
font: config.font,
size: config.size,
bold: config.bold,
italic: config.italic,
stroke: config.stroke,
shadow: config.shadow,
color: config.color,
posX: null,
posY: null,
}
}
/** 根据 preset key 找到对应的预设 */
function findPresetByKey(key: string): TitlePreset | undefined {
return titlePresets.find((p) => p.key === key)
}
const TitleConfigPanel: React.FC<TitleConfigPanelProps> = ({
titleConfig,
setTitleConfig,
collapsed,
onToggleCollapse,
}) => {
/* 字幕开关 */
const [subtitleEnabled, setSubtitleEnabled] = useState(false)
const [subtitleFont, setSubtitleFont] = useState("思源黑体")
const [subtitleSize, setSubtitleSize] = useState(24)
const titleSettings = useMemo(() => toTitleSettings(titleConfig), [titleConfig])
/* 当前激活的预设 */
const activePreset = useMemo(() => {
const match = titlePresets.find(
(p) =>
p.style.size === titleConfig.size &&
p.style.color === titleConfig.color &&
p.style.bold === titleConfig.bold &&
p.style.italic === titleConfig.italic &&
p.style.stroke === titleConfig.stroke &&
p.style.shadow === titleConfig.shadow,
)
return match ? match.key : null
}, [titleConfig])
/* 将 TitleStylePanel 的预设 key 映射回 titlePresets 项 */
const presetItems = useMemo(
() =>
titlePresets.map((p) => ({
key: p.key,
label: p.label,
previewStyle: p.previewStyle as React.CSSProperties,
})),
[],
)
const handleTitleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setTitleConfig({ ...titleConfig, title: e.target.value })
},
[titleConfig, setTitleConfig],
)
const handleApplyPreset = useCallback(
(presetKey: string) => {
const preset = findPresetByKey(presetKey)
if (!preset) return
setTitleConfig({
...titleConfig,
size: preset.style.size,
color: preset.style.color,
bold: preset.style.bold,
italic: preset.style.italic,
stroke: preset.style.stroke,
shadow: preset.style.shadow,
})
},
[titleConfig, setTitleConfig],
)
return (
<div className={`ai-avatar-panel panel-title-config ${collapsed ? "collapsed" : ""}`}>
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
<h3></h3>
<button className="collapse-btn"></button>
</div>
<div className="ai-avatar-panel-body">
{/* 主标题输入 */}
<div style={{ marginBottom: 16 }}>
<label style={{ display: "block", fontSize: 13, color: "#999", marginBottom: 6 }}>
</label>
<input
className="ai-avatar-script-editor"
style={{ minHeight: "auto", padding: "8px 12px", fontSize: 14 }}
type="text"
placeholder="请输入视频标题"
value={titleConfig.title}
onChange={handleTitleChange}
/>
</div>
{/* 复用标题样式面板 */}
<TitleStylePanel
settings={titleSettings}
onUpdatePosition={(position) => setTitleConfig({ ...titleConfig, position })}
onUpdateFont={(font) => setTitleConfig({ ...titleConfig, font })}
onUpdateSize={(size) => setTitleConfig({ ...titleConfig, size })}
onToggleBold={() => setTitleConfig({ ...titleConfig, bold: !titleConfig.bold })}
onToggleItalic={() => setTitleConfig({ ...titleConfig, italic: !titleConfig.italic })}
onToggleStroke={() => setTitleConfig({ ...titleConfig, stroke: !titleConfig.stroke })}
onToggleShadow={() => setTitleConfig({ ...titleConfig, shadow: !titleConfig.shadow })}
onApplyPreset={handleApplyPreset}
activePreset={activePreset}
titlePresets={presetItems}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
{/* 字幕区域 */}
<div className="ai-avatar-subtitle-section">
<label>
<input
type="checkbox"
checked={subtitleEnabled}
onChange={(e) => setSubtitleEnabled(e.target.checked)}
/>
</label>
{subtitleEnabled && (
<div style={{ marginTop: 12 }}>
<div style={{ marginBottom: 8 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
</label>
<select
className="ai-avatar-script-editor"
style={{ minHeight: "auto", padding: "6px 10px", fontSize: 13 }}
value={subtitleFont}
onChange={(e) => setSubtitleFont(e.target.value)}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
{subtitleSize}px
</label>
<input
type="range"
min={12}
max={72}
value={subtitleSize}
onChange={(e) => setSubtitleSize(Number(e.target.value))}
style={{ width: "100%" }}
/>
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default TitleConfigPanel
@@ -0,0 +1,256 @@
import { useState, useRef, useCallback } from "react"
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { VoiceCloneState, VoiceTone } from "../types/aiAvatar"
interface VoiceClonePanelProps {
voiceClone: VoiceCloneState
setVoiceClone: (v: VoiceCloneState) => void
selectedVoiceId: string
setSelectedVoiceId: (id: string) => void
collapsed: boolean
onToggleCollapse: () => void
}
const MAX_AUDIO_SIZE = 50 * 1024 * 1024 // 50MB
/** Mock 音色列表(后续接 API */
const MOCK_VOICES: VoiceTone[] = [
{
id: "voice-1",
name: "温柔女声",
description: "适合新闻播报和产品介绍",
gender: "女",
preview_url: "",
},
{
id: "voice-2",
name: "沉稳男声",
description: "适合企业宣传和培训视频",
gender: "男",
preview_url: "",
},
{
id: "voice-3",
name: "活泼女声",
description: "适合短视频和社交媒体内容",
gender: "女",
preview_url: "",
},
]
/** 状态文案映射 */
const STATUS_LABEL: Record<VoiceCloneState["status"], string> = {
idle: "",
uploading: "正在上传音频...",
cloning: "正在克隆声音...",
completed: "声音克隆完成",
failed: "克隆失败,请重试",
}
const STATUS_CLASS: Record<string, string> = {
completed: "success",
cloning: "cloning",
failed: "failed",
}
const VoiceClonePanel: React.FC<VoiceClonePanelProps> = ({
voiceClone,
setVoiceClone,
selectedVoiceId,
setSelectedVoiceId,
collapsed,
onToggleCollapse,
}) => {
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUpload = useCallback(
async (file: File) => {
if (file.size > MAX_AUDIO_SIZE) {
alert("音频文件大小不能超过 50MB")
return
}
const isAudio = file.type.startsWith("audio/")
if (!isAudio) {
alert("请上传 WAV 或 MP3 格式的音频文件")
return
}
try {
setVoiceClone({ ...voiceClone, status: "uploading", progress: 0 })
// 获取或创建默认项目和配音库
const project = await getOrCreateDefaultProject()
const library = await ensureDefaultLibrary({
project_id: project.id,
kind: "voice",
})
// 上传音频
const audioUrl = URL.createObjectURL(file)
setVoiceClone({
...voiceClone,
status: "uploading",
audioUrl,
audioName: file.name,
progress: 0,
})
await uploadAssetDirect({
file,
library_id: library.id,
onProgress: (p) => {
setVoiceClone({ ...voiceClone, audioUrl, audioName: file.name, progress: p })
},
})
// 开始克隆(模拟)
setVoiceClone({
...voiceClone,
status: "cloning",
audioUrl,
audioName: file.name,
progress: 100,
})
// TODO: 调用实际的声音克隆 API
// 模拟克隆完成
setTimeout(() => {
setVoiceClone({
...voiceClone,
status: "completed",
audioUrl,
audioName: file.name,
cloneJobId: `clone-${Date.now()}`,
voiceId: `voice-clone-${Date.now()}`,
progress: 100,
})
}, 3000)
} catch (err) {
const message = err instanceof Error ? err.message : "上传失败"
alert(message)
setVoiceClone({ ...voiceClone, status: "failed", progress: 0 })
}
},
[voiceClone, setVoiceClone],
)
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
}
e.target.value = ""
},
[handleUpload],
)
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) {
handleUpload(file)
}
},
[handleUpload],
)
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragOver(true)
}, [])
const handleDragLeave = useCallback(() => {
setDragOver(false)
}, [])
const handleVoiceSelect = useCallback(
(voiceId: string) => {
setSelectedVoiceId(voiceId)
},
[setSelectedVoiceId],
)
const showStatus = voiceClone.status !== "idle" && voiceClone.status !== "uploading"
return (
<div className={`ai-avatar-panel panel-voice-clone ${collapsed ? "collapsed" : ""}`}>
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
<h3></h3>
<button className="collapse-btn"></button>
</div>
<div className="ai-avatar-panel-body">
{/* 上传区域 */}
{voiceClone.audioUrl ? (
<div>
<div className="ai-avatar-media-preview">
<audio src={voiceClone.audioUrl} controls />
</div>
<div className="ai-avatar-media-info">
<span>{voiceClone.audioName}</span>
</div>
</div>
) : (
<div
className={`ai-avatar-upload-zone ${dragOver ? "drag-over" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<span className="upload-icon">🎙</span>
<div></div>
<div style={{ marginTop: 4, fontSize: 12 }}>WAV / MP3 50MB</div>
<input
ref={fileInputRef}
type="file"
accept="audio/wav,audio/mp3,audio/*"
onChange={handleFileSelect}
style={{ display: "none" }}
/>
</div>
)}
{/* 克隆状态 */}
{showStatus && (
<div className={`ai-avatar-clone-status ${STATUS_CLASS[voiceClone.status] || ""}`}>
{voiceClone.status === "cloning" && "⏳ "}
{voiceClone.status === "completed" && "✅ "}
{voiceClone.status === "failed" && "❌ "}
{STATUS_LABEL[voiceClone.status]}
</div>
)}
{/* 上传中进度 */}
{voiceClone.status === "uploading" && (
<div style={{ marginTop: 12, fontSize: 13, color: "#999" }}>
... {voiceClone.progress}%
</div>
)}
{/* 已有音色列表 */}
<div className="ai-avatar-voice-list">
<h4></h4>
{MOCK_VOICES.map((voice) => (
<div
key={voice.id}
className={`ai-avatar-voice-item ${selectedVoiceId === voice.id ? "selected" : ""}`}
onClick={() => handleVoiceSelect(voice.id)}
>
<div>
<div style={{ fontSize: 13, color: "#fff" }}>{voice.name}</div>
<div style={{ fontSize: 12, color: "#999", marginTop: 2 }}>{voice.description}</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
export default VoiceClonePanel
@@ -0,0 +1,132 @@
/**
* AI数字人页面全局状态管理 (#1798)
*/
import { useState, useCallback } from "react"
import type {
AvatarVideo,
VoiceCloneState,
Script,
LipsyncJob,
BRollSegment,
AiAvatarTitleConfig,
AiAvatarCoverConfig,
AiAvatarGenerateConfig,
AiAvatarRenderJob,
} from "../types/aiAvatar"
const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
title: "",
font: "思源黑体",
size: 28,
color: "#ffffff",
position: "bottom",
bold: false,
italic: false,
stroke: false,
shadow: false,
}
const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
enabled: false,
mode: "auto",
frame_time: 0,
upload_url: "",
thumbnail_url: "",
}
const DEFAULT_VOICE_CLONE: VoiceCloneState = {
status: "idle",
audioUrl: "",
audioName: "",
cloneJobId: "",
voiceId: "",
progress: 0,
}
export function useAiAvatarState() {
/* ── 面板折叠状态 ── */
const [collapsedPanels, setCollapsedPanels] = useState<Record<string, boolean>>({})
const togglePanel = useCallback((key: string) => {
setCollapsedPanels((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])
/* ── 面板1:出镜视频 ── */
const [avatarVideo, setAvatarVideo] = useState<AvatarVideo | null>(null)
/* ── 面板2:声音克隆 ── */
const [voiceClone, setVoiceClone] = useState<VoiceCloneState>(DEFAULT_VOICE_CLONE)
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("")
/* ── 面板3:文案 & 对口型 ── */
const [selectedScript, setSelectedScript] = useState<Script | null>(null)
const [scriptContent, setScriptContent] = useState("")
const [lipsyncJob, setLipsyncJob] = useState<LipsyncJob | null>(null)
const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([])
/* ── 面板4:标题配置 ── */
const [titleConfig, setTitleConfig] = useState<AiAvatarTitleConfig>(DEFAULT_TITLE_CONFIG)
/* ── 面板5:封面 & 生成 ── */
const [coverConfig, setCoverConfig] = useState<AiAvatarCoverConfig>(DEFAULT_COVER_CONFIG)
const [generateConfig, setGenerateConfig] = useState<AiAvatarGenerateConfig>({
resolution: "1080p",
bRollMode: "pip",
})
/* ── 渲染任务 ── */
const [renderJob, setRenderJob] = useState<AiAvatarRenderJob | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
/* ── 弹窗状态 ── */
const [scriptModalOpen, setScriptModalOpen] = useState(false)
const [brollModalOpen, setBrollModalOpen] = useState(false)
return {
// 面板折叠
collapsedPanels,
togglePanel,
// 面板1
avatarVideo,
setAvatarVideo,
// 面板2
voiceClone,
setVoiceClone,
selectedVoiceId,
setSelectedVoiceId,
// 面板3
selectedScript,
setSelectedScript,
scriptContent,
setScriptContent,
lipsyncJob,
setLipsyncJob,
bRollSegments,
setBRollSegments,
// 面板4
titleConfig,
setTitleConfig,
// 面板5
coverConfig,
setCoverConfig,
generateConfig,
setGenerateConfig,
// 渲染
renderJob,
setRenderJob,
isGenerating,
setIsGenerating,
// 弹窗
scriptModalOpen,
setScriptModalOpen,
brollModalOpen,
setBrollModalOpen,
}
}
@@ -0,0 +1,121 @@
/**
* AI数字人页面 — 类型定义 (#1798)
*/
/* ── 出镜视频 ── */
export interface AvatarVideo {
url: string
name: string
duration: number // 秒
width: number
height: number
size: number // 字节
}
/* ── 声音克隆 ── */
export interface VoiceTone {
id: string
name: string
description: string
gender: string
preview_url?: string
}
export interface VoiceCloneState {
status: "idle" | "uploading" | "cloning" | "completed" | "failed"
audioUrl: string
audioName: string
cloneJobId: string
voiceId: string
progress: number
}
/* ── 文案 ── */
export interface ScriptSegment {
text: string
duration?: number
}
export interface Script {
id: string
title: string
content: string
segments: ScriptSegment[]
tags: string[]
created_at: string
updated_at: string
}
/* ── 对口型 ── */
export interface LipsyncJob {
id: string
status: "pending" | "processing" | "completed" | "failed"
video_url: string
audio_url: string
output_video_url: string
output_duration: number
error_message: string
submitted_at?: string
completed_at?: string
}
/* ── B-roll 插入 ── */
export interface BRollSegment {
script_segment_index: number
asset_url: string
mode: "fullscreen" | "pip"
start_time: number
end_time: number
pip_position?: string
pip_scale?: number
}
/* ── 渲染任务 ── */
export interface AiAvatarRenderRequest {
lipsync_job_id: string
script_id: string
b_roll_segments: BRollSegment[]
title_config: Record<string, unknown>
cover_config: Record<string, unknown>
project_id?: string
}
export interface AiAvatarRenderJob {
id: string
status: "pending" | "processing" | "completed" | "failed"
progress: number
output_video_url: string
output_cover_url: string
output_duration: number
error_message: string
created_at: string
updated_at: string
}
/* ── 封面配置 ── */
export interface AiAvatarCoverConfig {
enabled: boolean
mode: "auto" | "frame" | "upload"
frame_time: number
upload_url: string
thumbnail_url: string
}
/* ── 标题配置(复用 generate 的 TitleSettings 结构) ── */
export interface AiAvatarTitleConfig {
title: string
font: string
size: number
color: string
position: string
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
}
/* ── 生成设置 ── */
export interface AiAvatarGenerateConfig {
resolution: "720p" | "1080p"
bRollMode: "fullscreen" | "pip"
}
@@ -6440,3 +6440,49 @@
opacity: 0.6;
cursor: not-allowed;
}
/* ═══ 标题设置 — 颜色预设 ═══ */
.ep-color-presets {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.ep-color-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid transparent;
cursor: pointer;
transition:
border-color 0.15s,
transform 0.1s;
}
.ep-color-swatch:hover {
transform: scale(1.1);
}
.ep-color-swatch.active {
border-color: var(--ep-primary, #4f8cff);
}
.ep-color-picker {
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
cursor: pointer;
padding: 0;
background: none;
}
.ep-color-picker::-webkit-color-swatch-wrapper {
padding: 0;
}
.ep-color-picker::-webkit-color-swatch {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
@@ -196,6 +196,8 @@ const EditingPlanner: React.FC = () => {
{/* 右栏 260px:设置面板 */}
<RightPanel
titleConfig={titleConfig}
onTitleConfigChange={setTitleConfig}
rightTab={rightTab}
onTabChange={setRightTab}
selectedClip={clipOps.selectedClip}
@@ -5,12 +5,15 @@
import React from "react"
import type { ClipPropertiesPanelProps } from "@/pages/editing-planner/types/clipProperties"
import SubtitleSettingsSection from "./clip-properties/SubtitleSettingsSection"
import TitleSettingsSection from "./clip-properties/TitleSettingsSection"
import BgmSettingsSection from "./clip-properties/BgmSettingsSection"
import ClipDetailSection from "./clip-properties/ClipDetailSection"
import StatsSection from "./clip-properties/StatsSection"
import { useVoicePreview } from "@/pages/editing-planner/hooks/useVoicePreview"
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
titleConfig,
onTitleConfigChange,
selectedClip,
subtitleSettings,
bgmSettings,
@@ -40,6 +43,11 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
return (
<div className="ep-right-panel">
{/* ═══ 标题设置 — #1789 ═══ */}
{titleConfig && onTitleConfigChange && (
<TitleSettingsSection config={titleConfig} onChange={onTitleConfigChange} />
)}
{/* ═══ 字幕设置 ═══ */}
<SubtitleSettingsSection
settings={subtitleSettings}
@@ -5,9 +5,12 @@ import type { ClipData } from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import type { TemplateMode } from "@/api/editing-planner"
import type { TitleConfig } from "@/api/template-editor"
import type { AssetItem } from "@/api/assets"
interface RightPanelProps {
titleConfig?: TitleConfig
onTitleConfigChange?: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
rightTab: "properties" | "clips"
onTabChange: (tab: "properties" | "clips") => void
// 属性 tab
@@ -46,6 +49,8 @@ interface RightPanelProps {
}
const RightPanel: React.FC<RightPanelProps> = ({
titleConfig,
onTitleConfigChange,
rightTab,
onTabChange,
selectedClip,
@@ -118,6 +123,8 @@ const RightPanel: React.FC<RightPanelProps> = ({
>["onBgmSettingsChange"]
return (
<ClipPropertiesPanel
titleConfig={titleConfig}
onTitleConfigChange={onTitleConfigChange}
selectedClip={selectedClip}
subtitleSettings={sub}
bgmSettings={bgm}
@@ -0,0 +1,136 @@
/**
* #1789
*
*/
import React from "react"
import type { TitleConfig } from "@/api/template-editor"
import { POSITION_OPTIONS, FONT_OPTIONS } from "@/pages/editing-planner/constants/clipProperties"
interface TitleSettingsSectionProps {
config: TitleConfig
onChange: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
}
const TITLE_COLOR_PRESETS = [
"#ffffff",
"#000000",
"#ff4444",
"#ffaa00",
"#44ff44",
"#4488ff",
"#ff44ff",
"#ffff44",
]
const TitleSettingsSection: React.FC<TitleSettingsSectionProps> = ({ config, onChange }) => {
const update = (partial: Partial<TitleConfig>) => {
onChange((prev: TitleConfig) => ({ ...prev, ...partial }))
}
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📝</span>
</div>
{/* AI 自动选择开关 */}
<div className="ep-toggle-row">
<span className="ep-toggle-label">AI </span>
<div
className={`ep-toggle ${config.ai_auto_select ? "active" : ""}`}
onClick={() => update({ ai_auto_select: !config.ai_auto_select })}
>
<div className="ep-toggle-knob" />
</div>
</div>
{!config.ai_auto_select && (
<>
{/* 标题文本 */}
<div className="ep-field">
<label className="ep-field-label"></label>
<input
className="ep-form-select"
type="text"
placeholder="输入标题内容"
value={config.content}
onChange={(e) => update({ content: e.target.value })}
/>
</div>
{/* 位置 */}
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={config.position}
onChange={(e) => update({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 字体预设 */}
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={config.font_preset}
onChange={(e) => update({ font_preset: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
{/* 字号滑块 */}
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={72}
value={config.font_size}
onChange={(e) => update({ font_size: Number(e.target.value) })}
/>
<span className="ep-slider-value">{config.font_size}px</span>
</div>
</div>
{/* 颜色 */}
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-color-presets">
{TITLE_COLOR_PRESETS.map((color) => (
<div
key={color}
className={`ep-color-swatch${config.font_color === color ? " active" : ""}`}
style={{ backgroundColor: color }}
onClick={() => update({ font_color: color })}
/>
))}
<input
type="color"
className="ep-color-picker"
value={config.font_color}
onChange={(e) => update({ font_color: e.target.value })}
/>
</div>
</div>
</>
)}
</div>
)
}
export default TitleSettingsSection
@@ -4,6 +4,7 @@
import type { ClipData } from "./clip"
import type { TemplateMode } from "@/api/editing-planner"
import type { AssetItem } from "@/api/assets"
import type { TitleConfig } from "@/api/template-editor"
export interface SubtitleSettings {
enabled: boolean
@@ -28,6 +29,10 @@ export interface BgmSettings {
}
export interface ClipPropertiesPanelProps {
/** 标题配置 — #1789 */
titleConfig?: TitleConfig
/** 标题配置变更 */
onTitleConfigChange?: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
selectedClip: ClipData | null
subtitleSettings: SubtitleSettings
bgmSettings: BgmSettings
@@ -13,6 +13,8 @@ export const formatTrimTime = (sec: number): string => {
/** 生成时间标尺刻度 */
export const generateRulerMarks = (totalDuration: number, step: number): number[] => {
const marks: number[] = []
// #1790: 无片段时不显示时间刻度
if (totalDuration <= 0) return marks
for (let t = 0; t <= totalDuration + step; t += step) {
marks.push(t)
}
+58 -53
View File
@@ -45,6 +45,7 @@ const GeneratePage: React.FC = () => {
selectedTemplate,
setSelectedTemplate,
userTemplates,
handleInvalidTemplate,
selectedMaterials,
setSelectedMaterials,
materialMode,
@@ -416,13 +417,11 @@ const GeneratePage: React.FC = () => {
/* ── 最终成片(单视频右侧播放) ── */
const finalVideo = generatedVideos[0]
/* ── 布局 class:步骤4标题页=预览+标题侧栏;步骤5/6批量=整行宽;步骤1~3=整行宽 ── */
/* ── 布局 class:步骤4标题页=预览+标题侧栏两栏;其余步骤(含步骤5确认生成、步骤6封面)=整行宽 ── */
const layoutClassName = useMemo(() => {
if (currentStep < 4) return "xx-generate-layout full-width"
if (currentStep === 4) return "xx-generate-layout step4-layout"
// 步骤5/6:批量网格需要整行宽度;单视频保持 表单+右侧成片 两栏
return isBatch ? "xx-generate-layout full-width" : "xx-generate-layout"
}, [currentStep, isBatch])
return "xx-generate-layout full-width"
}, [currentStep])
/* ================================================================
@@ -522,6 +521,7 @@ const GeneratePage: React.FC = () => {
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
onTemplateInvalid={handleInvalidTemplate}
generating={generating}
generated={generated}
generateError={generateError}
@@ -543,6 +543,59 @@ const GeneratePage: React.FC = () => {
selectedVariantIds={selectedVariantIds}
/>
{/* ════ 步骤5(单视频):成片播放器置于按钮上方、居中展示 ════ */}
{currentStep === 5 && !isBatch && generated && finalVideo && (
<div
style={{
display: "flex",
justifyContent: "center",
marginTop: 16,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
background: "#000",
borderRadius: 12,
padding: 8,
maxWidth: 320,
width: "100%",
}}
>
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay
style={{
width: "auto",
maxWidth: "100%",
maxHeight: "70vh",
aspectRatio: "9 / 16",
objectFit: "contain",
borderRadius: 8,
}}
poster={finalVideo.thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
</button>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁
</button>
</div>
</div>
</div>
)}
<GenerateStepActions
currentStep={currentStep}
onPrev={goPrev}
@@ -554,54 +607,6 @@ const GeneratePage: React.FC = () => {
selectedCount={isBatch ? selectedVariantIds.length : 1}
/>
</div>
{/* ════ 步骤5/6(单视频):右侧成片播放器 ════ */}
{currentStep >= 5 && !isBatch && generated && finalVideo && (
<div className="xx-generate-right-col">
<div
className="xx-inline-video-player"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "#000",
borderRadius: 12,
padding: 8,
}}
>
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay={currentStep === 5}
// 竖屏自适应(#1750):成片固定 1080×19209:16),元数据到达前按 9:16 占位,
// 到达后浏览器按真实宽高比 contain;黑底居中杜绝左右大黑边
style={{
width: "auto",
maxWidth: "100%",
maxHeight: "70vh",
aspectRatio: "9 / 16",
objectFit: "contain",
borderRadius: 8,
}}
poster={finalVideo.thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
</button>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁
</button>
</div>
</div>
</div>
)}
</div>
{/* 数量选择弹窗 */}
@@ -34,12 +34,26 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
{tasks.filter((t) => t.status === "completed").length} / {tasks.length}
</span>
</div>
<div className="xx-batch-gen-grid">
<div
className="xx-batch-gen-grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 320px))",
justifyContent: "center",
justifyItems: "center",
gap: 14,
width: "100%",
}}
>
{sorted.map((task) => {
const title = titles[task.variantIndex] || `视频 ${task.variantIndex + 1}`
const video = (task.videos?.[0] || null) as GeneratedVideo | null
return (
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
<div
key={task.taskId}
className={`xx-batch-gen-card status-${task.status}`}
style={{ maxWidth: 320 }}
>
<div className="xx-batch-gen-card-head">
<span className="xx-batch-gen-card-title" title={title}>
{task.status === "completed" ? (
@@ -50,6 +50,8 @@ export interface GenerateStepContentProps {
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
onServerClipsChange: (clips: EditPlanClip[]) => void
/** 当前模板创建片段被判失效(404/400/422)时的自动回退回调(#1777 */
onTemplateInvalid?: () => boolean
/* 生成 */
generating: boolean
generated: boolean
@@ -108,6 +110,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedVoice,
onSelectedVoiceChange,
onServerClipsChange,
onTemplateInvalid,
generating,
generated,
generateError,
@@ -153,6 +156,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedTemplate={selectedTemplate}
templateSegments={templateSegments}
onServerClipsChange={onServerClipsChange}
onTemplateInvalid={onTemplateInvalid}
/>
)
case 3:
@@ -189,7 +193,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
/>
)
case 5:
/* 确认生成页:批量=逐任务进度网格;单视频=进度状态卡(成片播放器在左侧大区域 */
/* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态(完成后只显示成片播放器,播放器在按钮上方 */
if (previewCount > 1) {
return (
<BatchGenerationGrid
@@ -199,10 +203,10 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
/>
)
}
/* 单视频:渲染进度 / 失败重试 / 完成提示(成片播放器在右侧栏) */
/* 单视频:生成中显示进度卡、失败显示重试卡;生成完成后不再渲染提示卡,页面只保留成片播放器+操作按钮 */
if (generated && !generating && !generateError) return null
return (
<div className="xx-form-section">
<h3>🎬 </h3>
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
@@ -234,14 +238,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
</button>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-info">
<div className="xx-gen-success-title"> </div>
<div className="xx-gen-success-sub"></div>
</div>
</div>
)}
</div>
)
case 6:
@@ -23,6 +23,8 @@ interface Step2MaterialSelectProps {
templateSegments?: TemplateSegment[]
/** 服务端 clips 创建成功后的回调 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
onTemplateInvalid?: () => boolean
}
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
@@ -36,16 +38,25 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label></label>
<select
value={m.selectedLibraryId}
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
>
{m.libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
{m.libraries.length === 0 && !m.materialsLoading ? (
<div className="xx-empty-state">
<p></p>
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
</p>
</div>
) : (
<select
value={m.selectedLibraryId}
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
>
{m.libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
)}
</div>
{m.materialMode === "manual" && (
@@ -1,117 +0,0 @@
import React from "react"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { GeneratedVideo } from "@/api/template-editor"
interface GenerationStatusProps {
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
getGenerationPhase: (progress: number) => { icon: string; label: string }
onScrollToPreview: () => void
onRetry: () => void
onDismissError: () => void
}
const GenerationStatus: React.FC<GenerationStatusProps> = ({
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
onScrollToPreview,
onRetry,
onDismissError,
}) => {
return (
<div style={{ marginTop: 16 }}>
{!generating && !generated && !generateError && (
<div className="xx-gen-progress-card" style={{ opacity: 0.85 }}>
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">🎬</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase"></div>
<div className="xx-gen-progress-sub">
</div>
</div>
</div>
</div>
)}
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">
<LoadingOutlined />
</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
</div>
<div className="xx-gen-progress-sub"> 1-2 </div>
</div>
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<div className="xx-gen-progress-tip">
💡
</div>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-icon">
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
</div>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title"></div>
<div className="xx-gen-success-sub">
{generatedVideos.length}
</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={onScrollToPreview}
>
</button>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-icon">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
</div>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title"></div>
<div className="xx-gen-error-msg">
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
🔄
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={onDismissError}
>
</button>
</div>
</div>
)}
</div>
)
}
export default GenerationStatus
+7 -29
View File
@@ -117,10 +117,6 @@
grid-template-columns: 1fr;
}
.xx-generate-layout.full-width .xx-generate-right-col {
display: none;
}
/* ============================================================
左侧表单区 generate-form
============================================================ */
@@ -2323,28 +2319,6 @@
生成结果右侧
================================================================ */
.xx-generate-right-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
/* ── 内联视频播放器(右侧) ── */
.xx-inline-video-player {
width: 100%;
max-width: 320px;
background: var(--bg-surface, #fff);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.xx-inline-video-player video {
background: #000;
}
.xx-preview-header {
display: flex;
align-items: center;
@@ -2710,11 +2684,12 @@
/* ── 封面设置区域改造样式 ── */
/* 封面操作按钮区 */
/* 封面操作按钮区(单视频全宽页居中) */
.xx-cover-actions {
display: flex;
gap: 12px;
margin-bottom: 12px;
justify-content: center;
}
/* 已选模板文字 */
@@ -3202,10 +3177,11 @@
margin: 0;
}
/* ── 批量封面网格 ── */
/* ── 批量封面网格(单卡/少卡时居中排列,卡片限宽不拉伸) ── */
.xx-cover-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(180px, 220px));
justify-content: center;
gap: 16px;
}
@@ -3403,6 +3379,7 @@
第5步确认生成批量渲染进度网格Issue #1677
============================================================ */
.xx-batch-gen-grid {
justify-items: center;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
justify-content: center;
@@ -3481,6 +3458,7 @@
/* ── 响应式:窄屏批量网格回退单列(.xx-canvas-grid 的窄屏限宽见网格定义处 #1741) ── */
@media (max-width: 960px) {
.xx-batch-gen-grid {
justify-items: center;
grid-template-columns: minmax(0, 320px);
}
}
@@ -8,11 +8,22 @@ import type { AssetItem } from "@/api/assets"
*
*/
export function useMaterialLibrary() {
/* ── 素材库数据 API ── */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
/* API
* Step2 kind=video #1777
* kind getAssetLibraries("video")
* (voice)/(image)
* queryKey kind/ ["asset-libraries"]
*/
const { data: allLibraries = [] } = useQuery({
queryKey: ["asset-libraries", "video"],
queryFn: () => getAssetLibraries("video"),
staleTime: 60_000,
})
// 前端兜底过滤:仅保留 kind=video 的素材库(后端按 kind 查询参数过滤)
const libraries = useMemo(
() => allLibraries.filter((lib) => lib.kind === "video"),
[allLibraries],
)
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频库
@@ -40,6 +40,8 @@ export interface GenerateFormState {
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/** 当前选中模板在创建片段时被判失效(404/400/422)后的运行时自动回退 */
handleInvalidTemplate: () => boolean
/* 素材 */
selectedMaterials: string[]
@@ -131,7 +133,8 @@ export const useGenerateFormState = (): GenerateFormState => {
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板选择 ── */
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
const { selectedTemplate, setSelectedTemplate, userTemplates, handleInvalidTemplate } =
useTemplateSelection()
/* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */
// selectedTemplate 是模板 ID 而非 edit_plan_id,不能混淆;
@@ -228,6 +231,7 @@ export const useGenerateFormState = (): GenerateFormState => {
selectedTemplate,
setSelectedTemplate,
userTemplates,
handleInvalidTemplate,
selectedMaterials,
setSelectedMaterials,
materialMode,
@@ -0,0 +1,81 @@
/**
* 退#1777
*
*
* PUT/POST /templates/{id}/editor/clips[...]/from-assets
* - 404 #1774
* - 400detail
* 422
*
*
*/
import type { EditingTemplate } from "@/api/editing-planner"
/** 失效模板相关的 HTTP 状态码 */
const INVALID_TEMPLATE_STATUSES = new Set([404, 400, 422])
/**
* axios HTTP
* axios / null
*/
export function getHttpStatus(err: unknown): number | null {
if (!err || typeof err !== "object") return null
const status = (err as { response?: { status?: number }; status?: number })?.response?.status
return typeof status === "number" ? status : null
}
/** 安全提取后端错误文本(detail/message/msg422 数组也兜底拼一下) */
function extractErrorText(err: unknown): string {
if (!err || typeof err !== "object") return ""
const data = (err as { response?: { data?: unknown } })?.response?.data
if (!data) return ""
try {
const text = JSON.stringify(data)
return typeof text === "string" ? text : ""
} catch {
return ""
}
}
/**
* clips/from-assets
*
* 400/422
* - 404/
* - 400
* - 422from-assets /segments
*/
export function isInvalidTemplateError(err: unknown): boolean {
const status = getHttpStatus(err)
if (status === null || !INVALID_TEMPLATE_STATUSES.has(status)) return false
if (status === 404) return true
const text = extractErrorText(err)
if (status === 400) {
// 后端当前返回:「模板没有片段配置,无法创建片段」
return /片段配置|没有片段|无片段|segments?|clip.*config/i.test(text)
}
// 422:FastAPI 校验错误,命中模板片段相关字段
return /segment|clip|片段|模板/i.test(text)
}
/**
*
*
* = is_active !== false true
*
* valid_only #1769/#1772
*/
export function isValidTemplate(template: EditingTemplate | null | undefined): boolean {
if (!template) return false
if (template.is_active === false) return false
return (template.segments?.length ?? 0) > 0
}
/** 从模板列表中取出第一个有效模板,没有则返回 null */
export function findFirstValidTemplate(
templates: EditingTemplate[] | null | undefined,
): EditingTemplate | null {
if (!Array.isArray(templates)) return null
return templates.find(isValidTemplate) ?? null
}
@@ -1,22 +1,89 @@
import { useState, useEffect } from "react"
import { useState, useEffect, useRef, useCallback, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { message } from "antd"
import { getEditingTemplates } from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
import { findFirstValidTemplate, isValidTemplate } from "./templateFallback"
/** 失效模板自动切换的提示文案 */
export const INVALID_TEMPLATE_FALLBACK_TOAST = "原模板已失效,已自动切换"
export function useTemplateSelection() {
// selectedTemplate 纯内存状态,绝不写入 localStorage/sessionStorage/URL
// 因此失效模板 ID 不会被持久化、刷新后也不会恢复(#1777 要求 4)
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
const { data: allTemplates = [] } = useQuery<EditingTemplate[]>({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
// valid_only:后端过滤掉没有片段配置的无效模板(#1769/#1772)。
// 旧后端忽略该 query 参数时,下方 isValidTemplate 前端兜底再过滤一次。
queryFn: () => getEditingTemplates({ validOnly: true }),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
// 双保险:后端 valid_only 已过滤,前端再按 is_active + segments 兜底,
// 保证下拉/自动选择只包含可用于生成的有效模板。
// 用 useMemo 缓存引用,避免每次渲染都 .filter 创建新数组,
// 导致下游 useTitleCoverSync effect 无限触发、覆盖用户手动修改(#1789)
const validTemplates = useMemo(() => allTemplates.filter(isValidTemplate), [allTemplates])
const userTemplates = validTemplates
return { selectedTemplate, setSelectedTemplate, userTemplates }
// 用 ref 持有最新值,供稳定回调 handleInvalidTemplate 使用(避免闭包拿到旧值)
const templatesRef = useRef(validTemplates)
templatesRef.current = validTemplates
const selectedRef = useRef(selectedTemplate)
selectedRef.current = selectedTemplate
// 已提示过失效的模板 ID,避免用户停留在失效模板上时 clips 防抖请求反复弹 toast
// 用户手动切换/成功切换后重置,保证下一个失效模板仍能提示
const fallbackNotifiedRef = useRef<string>("")
/*
* setSelectedTemplate selectedTemplate
* effect return#1777 4 */
useEffect(() => {
if (selectedTemplate) return
const firstValid = validTemplates[0]
if (firstValid) {
setSelectedTemplate(firstValid.id)
}
}, [validTemplates, selectedTemplate])
/** 用户手动选择模板:优先级最高,重置失效提示标记 */
const handleSelectTemplate = useCallback((id: string) => {
fallbackNotifiedRef.current = ""
setSelectedTemplate(id)
}, [])
/**
* 退#1777 3
* 404/ 400/422
* - toast
* - Step1
*
* true
*/
const handleInvalidTemplate = useCallback((): boolean => {
const current = selectedRef.current
// 同一个失效模板只提示一次(clips 防抖 effect 在素材/模板变化时会反复触发)
if (current && fallbackNotifiedRef.current === current) return true
const fallback = findFirstValidTemplate(templatesRef.current)
fallbackNotifiedRef.current = current || "__empty__"
if (fallback) {
setSelectedTemplate(fallback.id)
message.warning(INVALID_TEMPLATE_FALLBACK_TOAST)
} else {
// 没有任何有效模板:清空选择,交由 Step1 空状态引导用户去模板编辑器创建
setSelectedTemplate("")
message.warning("当前没有可用模板,请先在「模板编辑器」中创建并配置片段")
}
return true
}, [])
return {
selectedTemplate,
setSelectedTemplate: handleSelectTemplate,
userTemplates,
handleInvalidTemplate,
}
}
@@ -1,4 +1,4 @@
import { useEffect } from "react"
import { useEffect, useRef } from "react"
import type { TitleSettings } from "../../types"
import type { CoverConfig } from "../../types/cover"
import type { EditingTemplate } from "@/api/editing-planner"
@@ -11,7 +11,11 @@ interface UseTitleCoverSyncOptions {
}
/**
*
*
*
* #1789 userTemplates ref
* .filter() effect
* title_config
*/
export function useTitleCoverSync({
selectedTemplate,
@@ -19,8 +23,12 @@ export function useTitleCoverSync({
setTitleSettings,
setCoverSettings,
}: UseTitleCoverSyncOptions) {
// 用 ref 持有最新 userTemplates,避免数组引用变化导致 effect 反复触发
const templatesRef = useRef(userTemplates)
templatesRef.current = userTemplates
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
const tpl = templatesRef.current.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev: TitleSettings) => ({
...prev,
@@ -43,5 +51,7 @@ export function useTitleCoverSync({
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
}, [selectedTemplate, userTemplates, setTitleSettings, setCoverSettings])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTemplate, setTitleSettings, setCoverSettings])
// ↑ 移除 userTemplates,只在 selectedTemplate 真正变化时触发
}
@@ -10,6 +10,7 @@ import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
import { useDraftAutoSave } from "./useDraftAutoSave"
import { isInvalidTemplateError } from "./useGenerateFormState/templateFallback"
interface UseStep2MaterialsProps {
materialMode: "manual" | "auto"
@@ -24,6 +25,8 @@ interface UseStep2MaterialsProps {
templateSegments?: TemplateSegment[]
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
onTemplateInvalid?: () => boolean
}
export function useStep2Materials({
@@ -36,6 +39,7 @@ export function useStep2Materials({
selectedTemplate,
templateSegments,
onServerClipsChange,
onTemplateInvalid,
}: UseStep2MaterialsProps) {
const {
libraries,
@@ -102,6 +106,8 @@ export function useStep2Materials({
selectedTemplateRef.current = selectedTemplate
const onServerClipsChangeRef = useRef(onServerClipsChange)
onServerClipsChangeRef.current = onServerClipsChange
const onTemplateInvalidRef = useRef(onTemplateInvalid)
onTemplateInvalidRef.current = onTemplateInvalid
useEffect(() => {
const tid = selectedTemplateRef.current
@@ -123,11 +129,12 @@ export function useStep2Materials({
const requiredClipsCount = segs.length > 0 ? segs.length : undefined
try {
// 1. 清空旧片段
await updateEditPlanClips(tid, [], controller.signal)
// 1. 清空旧片段(静默全局 toast:模板失效时由下方回退统一提示)
await updateEditPlanClips(tid, [], controller.signal, true)
// 2. 调用后端 from-assets 接口创建片段(异步秒级返回,60s 超时仅为兜底)
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
signal: controller.signal,
silentErrorToast: true,
})
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
const clipList = await getEditPlanClips(tid, { limit: 500 })
@@ -146,6 +153,14 @@ export function useStep2Materials({
message.error("智能选片失败,请重试")
return
}
// 模板失效(404 模板不存在 / 400/422 无片段配置):
// 清空失效选择并自动切到第一个有效模板 + toast,避免页面卡死无提示(#1777)
if (isInvalidTemplateError(err)) {
console.warn("[useStep2Materials] 当前模板已失效,触发自动回退:", err)
onServerClipsChangeRef.current?.([])
onTemplateInvalidRef.current?.()
return
}
console.warn("[useStep2Materials] 写入 clips 失败:", err)
}
}, 800)
@@ -41,7 +41,7 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
queryFn: () => getAssetLibraries(),
})
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
@@ -24,7 +24,7 @@ export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMateri
// ── 获取 voice 类型素材库 ─────────────────────────────────
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
queryFn: () => getAssetLibraries(),
staleTime: 60_000,
})
@@ -26,7 +26,7 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
/* 获取或创建默认配音库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
queryFn: () => getAssetLibraries(),
})
const lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
+4
View File
@@ -56,6 +56,10 @@ const appChildren: RouteObject[] = [
path: "editing-planner",
lazy: lazyRoute(() => import("@/pages/editing-planner/EditingPlanner")),
},
{
path: "ai-avatar",
lazy: lazyRoute(() => import("@/pages/ai-avatar/AiAvatarPage")),
},
{
path: "my-templates",
lazy: lazyRoute(() => import("@/pages/my-templates/MyTemplates")),
@@ -0,0 +1,140 @@
/**
* /退#1777
*/
import { describe, it, expect } from "vitest"
import type { EditingTemplate } from "@/api/editing-planner"
import {
getHttpStatus,
isInvalidTemplateError,
isValidTemplate,
findFirstValidTemplate,
} from "@/pages/generate/hooks/useGenerateFormState/templateFallback"
function makeTemplate(partial: Partial<EditingTemplate> & { id: string }): EditingTemplate {
return {
name: partial.id,
mode: "pip",
category: "默认",
tags: [],
title_config: {
ai_auto_select: false,
content: "",
font_preset: "",
font_color: "",
font_size: 28,
position: "top",
},
subtitle_config: {
enabled: true,
position: "bottom",
font: "",
color: "",
size: 20,
animation: "",
},
bgm_config: { enabled: false, music_id: "" },
segments: [{ segment_order: 0, material_type: null }],
is_active: true,
created_at: "",
updated_at: "",
...partial,
} as EditingTemplate
}
function axiosError(status: number, data?: unknown) {
return { isAxiosError: true, response: { status, data } }
}
describe("getHttpStatus", () => {
it("提取 axios 错误的 HTTP 状态码", () => {
expect(getHttpStatus(axiosError(404))).toBe(404)
expect(getHttpStatus(axiosError(400))).toBe(400)
})
it("非 axios/无响应错误返回 null", () => {
expect(getHttpStatus(new Error("network"))).toBeNull()
expect(getHttpStatus(null)).toBeNull()
expect(getHttpStatus(undefined)).toBeNull()
expect(getHttpStatus({ isAxiosError: true })).toBeNull()
})
})
describe("isInvalidTemplateError", () => {
it("404 始终判定为模板失效(模板不存在)", () => {
expect(isInvalidTemplateError(axiosError(404))).toBe(true)
expect(isInvalidTemplateError(axiosError(404, { detail: "Not Found" }))).toBe(true)
})
it("400 且后端文案提到「片段配置」判定为模板无片段配置", () => {
expect(
isInvalidTemplateError(axiosError(400, { detail: "模板没有片段配置,无法创建片段" })),
).toBe(true)
})
it("400 但文案与片段配置无关 → 不误判", () => {
expect(isInvalidTemplateError(axiosError(400, { detail: "素材参数错误" }))).toBe(false)
})
it("422 命中片段/模板字段判定为失效", () => {
expect(
isInvalidTemplateError(
axiosError(422, { detail: [{ loc: ["body", "segments"], msg: "field required" }] }),
),
).toBe(true)
})
it("其他状态码(401/403/500/超时/网络)不判定为模板失效", () => {
expect(isInvalidTemplateError(axiosError(401))).toBe(false)
expect(isInvalidTemplateError(axiosError(403))).toBe(false)
expect(isInvalidTemplateError(axiosError(500))).toBe(false)
expect(isInvalidTemplateError({ code: "ECONNABORTED", message: "timeout of 60000ms" })).toBe(
false,
)
expect(isInvalidTemplateError(new Error("Network Error"))).toBe(false)
})
})
describe("isValidTemplate", () => {
it("有片段且未被标记 inactive → 有效", () => {
expect(isValidTemplate(makeTemplate({ id: "t1" }))).toBe(true)
})
it("segments 为空 → 无效(无片段配置)", () => {
expect(isValidTemplate(makeTemplate({ id: "t2", segments: [] }))).toBe(false)
})
it("is_active=false → 无效(已停用/删除)", () => {
expect(isValidTemplate(makeTemplate({ id: "t3", is_active: false }))).toBe(false)
})
it("is_active 字段缺失时视为有效(兼容旧后端)", () => {
const t = makeTemplate({ id: "t4" })
delete (t as Partial<EditingTemplate>).is_active
expect(isValidTemplate(t)).toBe(true)
})
it("null/undefined → 无效", () => {
expect(isValidTemplate(null)).toBe(false)
expect(isValidTemplate(undefined)).toBe(false)
})
})
describe("findFirstValidTemplate", () => {
it("跳过无效模板,返回第一个有效模板", () => {
const list = [
makeTemplate({ id: "empty", segments: [] }),
makeTemplate({ id: "inactive", is_active: false }),
makeTemplate({ id: "valid1" }),
makeTemplate({ id: "valid2" }),
]
expect(findFirstValidTemplate(list)?.id).toBe("valid1")
})
it("全部无效 → null(用于空状态引导)", () => {
expect(
findFirstValidTemplate([
makeTemplate({ id: "a", segments: [] }),
makeTemplate({ id: "b", is_active: false }),
]),
).toBeNull()
})
it("空数组/null → null", () => {
expect(findFirstValidTemplate([])).toBeNull()
expect(findFirstValidTemplate(null)).toBeNull()
expect(findFirstValidTemplate(undefined)).toBeNull()
})
})
@@ -0,0 +1,74 @@
/**
* useMaterialLibrary Hook #1777
* - Step2 kind=video (voice)/(image)
* -
*/
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
import type { AssetItem, AssetLibraryItem } from "@/api/assets"
vi.mock("@/api/assets", () => ({
getAssetLibraries: vi.fn(),
getAssets: vi.fn(),
isAssetUsable: vi.fn(() => true),
}))
import { getAssetLibraries, getAssets } from "@/api/assets"
import { useMaterialLibrary } from "@/pages/generate/hooks/step2-materials/useMaterialLibrary"
const mockGetLibraries = vi.mocked(getAssetLibraries)
const mockGetAssets = vi.mocked(getAssets)
function lib(id: string, kind: AssetLibraryItem["kind"], name = id): AssetLibraryItem {
return { id, name, kind }
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return ({ children }: { children: ReactNode }) =>
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) as ReactNode
}
beforeEach(() => {
vi.clearAllMocks()
mockGetAssets.mockResolvedValue({ items: [] as AssetItem[], total: 0 })
})
describe("useMaterialLibrary (#1777 kind=video 过滤)", () => {
it("按 kind=video 拉取素材库(后端参数过滤)", async () => {
mockGetLibraries.mockResolvedValueOnce([lib("v1", "video")])
renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
await waitFor(() => expect(mockGetLibraries).toHaveBeenCalledTimes(1))
expect(mockGetLibraries).toHaveBeenCalledWith("video")
})
it("下拉库列表只包含视频库(自动选中第一个视频库)", async () => {
mockGetLibraries.mockResolvedValueOnce([
lib("voice-1", "voice"),
lib("img-1", "image"),
lib("video-1", "video"),
lib("video-2", "video"),
])
const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.libraries).toHaveLength(2))
expect(result.current.libraries.map((l) => l.id)).toEqual(["video-1", "video-2"])
expect(result.current.libraries.every((l) => l.kind === "video")).toBe(true)
// 自动选中第一个视频库
expect(result.current.selectedLibraryId).toBe("video-1")
})
it("没有视频库时库列表为空且不自动选中(UI 展示空状态)", async () => {
mockGetLibraries.mockResolvedValueOnce([lib("voice-1", "voice"), lib("img-1", "image")])
const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
await waitFor(() => expect(mockGetLibraries).toHaveBeenCalled())
expect(result.current.libraries).toEqual([])
expect(result.current.selectedLibraryId).toBe("")
})
})
@@ -0,0 +1,174 @@
/**
* useTemplateSelection Hook #1777
* - /inactive
* - validOnly=true
* -
* - handleInvalidTemplate + toast
* - selectedTemplate localStorage/sessionStorage
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
// antd message mock(拦截 toast)——vi.hoisted 保证 mock 工厂可引用
const { messageMock } = vi.hoisted(() => ({
messageMock: {
warning: vi.fn(),
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
loading: vi.fn(() => vi.fn()),
},
}))
vi.mock("antd", () => ({ message: messageMock }))
vi.mock("@/api/editing-planner", () => ({
getEditingTemplates: vi.fn(),
}))
import { getEditingTemplates } from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
import { useTemplateSelection } from "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
const mockGetTemplates = vi.mocked(getEditingTemplates)
function tpl(id: string, partial: Partial<EditingTemplate> = {}): EditingTemplate {
return {
id,
name: id,
mode: "pip",
category: "默认",
tags: [],
title_config: {
ai_auto_select: false,
content: "",
font_preset: "",
font_color: "",
font_size: 28,
position: "top",
},
subtitle_config: {
enabled: true,
position: "bottom",
font: "",
color: "",
size: 20,
animation: "",
},
bgm_config: { enabled: false, music_id: "" },
segments: [{ segment_order: 0, material_type: null }],
is_active: true,
created_at: "",
updated_at: "",
...partial,
} as EditingTemplate
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return ({ children }: { children: ReactNode }) =>
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) as ReactNode
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
sessionStorage.clear()
})
afterEach(() => {
localStorage.clear()
sessionStorage.clear()
})
describe("useTemplateSelection (#1777)", () => {
it("请求模板时传 validOnly=true,并自动选中第一个有片段的有效模板", async () => {
mockGetTemplates.mockResolvedValueOnce([
tpl("empty", { segments: [] }),
tpl("inactive", { is_active: false }),
tpl("valid-a"),
tpl("valid-b"),
])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.selectedTemplate).toBe("valid-a"))
expect(mockGetTemplates).toHaveBeenCalledWith({ validOnly: true })
// 暴露给 UI 的 userTemplates 已过滤掉无效模板
expect(result.current.userTemplates.map((t) => t.id)).toEqual(["valid-a", "valid-b"])
})
it("列表全部无效时 selectedTemplate 为空(交空状态引导),不选中失效模板", async () => {
mockGetTemplates.mockResolvedValueOnce([
tpl("empty", { segments: [] }),
tpl("inactive", { is_active: false }),
])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(mockGetTemplates).toHaveBeenCalled())
// 给 effect 一个 tick
await waitFor(() => expect(result.current.selectedTemplate).toBe(""))
expect(result.current.userTemplates).toHaveLength(0)
})
it("用户手动选择优先:自动逻辑不会覆盖手动选择", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("a"), tpl("b")])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.selectedTemplate).toBe("a"))
act(() => result.current.setSelectedTemplate("b"))
expect(result.current.selectedTemplate).toBe("b")
// 重新渲染 / refetch 后仍保持用户的手动选择
await waitFor(() => expect(result.current.selectedTemplate).toBe("b"))
})
it("handleInvalidTemplate:当前模板失效时自动切到第一个有效模板并 toast", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] }), tpl("good")])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
// 自动选中有效模板 good(bad 无片段不会被自动选中)
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
messageMock.warning.mockClear()
// 模拟运行时用户停留在一个已失效的模板 id(外部/草稿态),触发回退
act(() => result.current.setSelectedTemplate("stale-id"))
expect(result.current.selectedTemplate).toBe("stale-id")
act(() => {
const handled = result.current.handleInvalidTemplate()
expect(handled).toBe(true)
})
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
expect(messageMock.warning).toHaveBeenCalledWith("原模板已失效,已自动切换")
})
it("handleInvalidTemplate:无有效模板时清空选择并提示去创建", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] })])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.userTemplates).toHaveLength(0))
act(() => result.current.setSelectedTemplate("stale-id"))
act(() => {
result.current.handleInvalidTemplate()
})
await waitFor(() => expect(result.current.selectedTemplate).toBe(""))
expect(messageMock.warning).toHaveBeenCalledWith(expect.stringContaining("没有可用模板"))
})
it("失效模板 ID 不写入任何持久化存储", async () => {
mockGetTemplates.mockResolvedValueOnce([tpl("good")])
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
act(() => result.current.setSelectedTemplate("stale-invalid-id"))
act(() => result.current.handleInvalidTemplate())
const ls = JSON.stringify(localStorage)
const ss = JSON.stringify(sessionStorage)
expect(ls).not.toContain("stale-invalid-id")
expect(ss).not.toContain("stale-invalid-id")
// URL 也不含
expect(window.location.href).not.toContain("stale-invalid-id")
})
})
+31 -4
View File
@@ -39,6 +39,8 @@ class BGMConfig:
sidechain_attack: float = 0.02 # 攻击时间
sidechain_release: float = 0.5 # 释放时间
sidechain_threshold: float = -25.0 # 触发阈值(dB
audio_offset: float = 0.0 # BGM 段落起始偏移(秒),#1767 策略二
volume_adjust_db: float = 0.0 # 音量微调 dB-3~+3),#1767 策略三
@classmethod
def from_config_dict(cls, bgm_path: str, config: dict) -> "BGMConfig":
@@ -54,6 +56,8 @@ class BGMConfig:
sidechain_attack=float(config.get("sidechain_attack", 0.02)),
sidechain_release=float(config.get("sidechain_release", 0.5)),
sidechain_threshold=float(config.get("sidechain_threshold", -25.0)),
audio_offset=float(config.get("audio_offset", 0.0)),
volume_adjust_db=float(config.get("volume_adjust_db", 0.0)),
)
@@ -84,14 +88,18 @@ def prepare_bgm_track(
target_duration = 5.0 # 兜底
bgm_dur = probe_duration(bgm.bgm_path)
needs_loop = bgm.loop_enabled and bgm_dur > 0 and bgm_dur < target_duration * 0.9
# #1767seek 后有效时长 = 总时长 - 偏移
effective_dur = (
max(1.0, bgm_dur - bgm.audio_offset) if bgm.audio_offset > 0 and bgm_dur > bgm.audio_offset else bgm_dur
)
needs_loop = bgm.loop_enabled and effective_dur > 0 and effective_dur < target_duration * 0.9
# 构建滤镜链
filter_parts: list[str] = []
if needs_loop:
# 计算需要循环多少次才能铺满
loop_count = max(1, int(target_duration / bgm_dur) + 2)
# 计算需要循环多少次才能铺满(基于 seek 后有效时长)
loop_count = max(1, int(target_duration / effective_dur) + 2)
# aloop 滤镜:循环指定次数
filter_parts.append(f"aloop=loop={loop_count}:size=0")
@@ -113,11 +121,30 @@ def prepare_bgm_track(
filter_parts.append(f"atrim=0:{target_duration:.3f}")
filter_parts.append("asetpts=N/SR/TB") # 重置时间戳
filter_str = ",".join(filter_parts)
# #1767BGM 段落差异化 — 使用 -ss 从偏移位置开始(seek 效率高,不读跳过部分)
seek_args: list[str] = []
if bgm.audio_offset > 0 and bgm_dur > bgm.audio_offset:
seek_args = ["-ss", f"{bgm.audio_offset:.3f}"]
logger.info("[bgm] #1767 audio_offset=%.1fs(段落差异化)", bgm.audio_offset)
# #1767:音量微调 — dB 转线性系数(10^(dB/20)
db_adjust_filter = ""
if abs(bgm.volume_adjust_db) > 0.01:
linear_factor = 10.0 ** (bgm.volume_adjust_db / 20.0)
db_adjust_filter = f",volume={linear_factor:.4f}"
logger.info("[bgm] #1767 volume_adjust=%.0fdB → linear=%.4f", bgm.volume_adjust_db, linear_factor)
# #1767:追加 dB 微调到滤镜链末尾
if db_adjust_filter:
filter_str_base = ",".join(filter_parts)
filter_str = filter_str_base + db_adjust_filter
else:
filter_str = ",".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
*seek_args,
"-i",
bgm.bgm_path,
"-filter:a",
+181 -10
View File
@@ -7,6 +7,7 @@ import hashlib
import logging
import math
import os
import re
import statistics
import tempfile
from dataclasses import dataclass, field
@@ -56,8 +57,13 @@ MAX_GAP = 2 # 允许的最大间隙帧数
NEIGHBOR_WINDOW = 1 # 分片时序对齐:允许 ±1 邻接偏移(1s 密集采样下即 ±1s,缓解切点不一致)
# ── 融合判定常量 ────────────────────────────────────────────────
PHASH_WEIGHT = 0.7 # pHash 权重
HISTOGRAM_WEIGHT = 0.3 # 直方图权重
PHASH_WEIGHT = 0.7 # pHash 权重(视觉内部)
HISTOGRAM_WEIGHT = 0.3 # 直方图权重(视觉内部)
# ── 多维度查重融合权重(Issue #P2-后端3) ────────────────────────
VISUAL_WEIGHT = 0.5 # 视觉相似度权重(pHash+直方图)
TEXT_WEIGHT = 0.25 # 文案相似度权重(配音文本)
STRUCTURE_WEIGHT = 0.25 # 结构相似度权重(片段序列)
MATCH_RATIO_THRESHOLD = 0.7 # 全片重复(is_duplicate)至少 70% 帧匹配
PARTIAL_COVERAGE_THRESHOLD = 0.5 # 局部复用覆盖率 >=50% 也判全片重复
DUPLICATE_THRESHOLD = 0.70 # 融合后相似度阈值
@@ -1057,12 +1063,11 @@ class VideoDeduplicator:
) -> dict:
"""计算当前视频与已有视频的查重率百分比。
公式双指标加权
- frame_match_rate = 汉明距离 < PHASH_THRESHOLD 的帧数 / 总帧数
- temporal_coverage_rate = 连续匹配片段总时长 / 视频总时长
- duplicate_rate = (frame_match_rate * 0.4 + temporal_coverage_rate * 0.6) * 100
visual_similarity = 0.7 * phash_sim + 0.3 * hist_sim归一化到 0~1
多维度融合公式Issue #P2-后端3):
- visual_similarity = 0.7 * phash_sim + 0.3 * hist_sim视觉维度
- text_similarity = 文案 Jaccard 相似度文案维度
- structure_similarity = 片段序列相似度结构维度
- duplicate_rate = (visual*0.5 + text*0.25 + structure*0.25) * 100
对每个匹配视频都算取最高 duplicate_rate
@@ -1093,6 +1098,29 @@ class VideoDeduplicator:
match_count = 0
evaluated = 0
# Issue #P2-后端3: 加载当前视频的文案+结构数据
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, GeneratedVideoModel
current_video_obj = (
session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == current_video_id).first()
if current_video_id
else None
)
current_plan_id = getattr(current_video_obj, "edit_plan_id", "") or ""
current_clips_data = []
current_text_content = ""
if current_plan_id:
current_clips = (
session.query(EditPlanClipModel)
.filter(EditPlanClipModel.plan_id == current_plan_id)
.order_by(EditPlanClipModel.order)
.all()
)
current_clips_data = [{"clip_type": c.clip_type, "duration": c.duration} for c in current_clips]
# 拼接所有片段的文本内容
current_text_content = " ".join(c.text_content for c in current_clips if c.text_content)
for existing in existing_videos:
if current_video_id and existing.id == current_video_id:
continue
@@ -1160,8 +1188,47 @@ class VideoDeduplicator:
# Issue #1702: 去掉 "frame_match_rate<0.3 整条跳过" 硬门槛——
# 局部片段复用帧比例天然低;coverage 为主指标,0 匹配自然得 0 分。
# duplicate_rate = 0.4 * frame_match_rate + 0.6 * temporal_coverage
dup_rate = (min(ev["frame_match_rate"], 1.0) * 0.4 + ev["temporal_coverage"] * 0.6) * 100
# 视觉维度:0.4 * frame_match_rate + 0.6 * temporal_coverage
visual_sim = min(ev["frame_match_rate"], 1.0) * 0.4 + ev["temporal_coverage"] * 0.6
# Issue #P2-后端3: 文案+结构维度
existing_plan_id = getattr(existing, "edit_plan_id", "") or ""
existing_clips_data = []
existing_text_content = ""
if existing_plan_id:
existing_clips = (
session.query(EditPlanClipModel)
.filter(EditPlanClipModel.plan_id == existing_plan_id)
.order_by(EditPlanClipModel.order)
.all()
)
existing_clips_data = [{"clip_type": c.clip_type, "duration": c.duration} for c in existing_clips]
existing_text_content = " ".join(c.text_content for c in existing_clips if c.text_content)
# 计算文案相似度(有文案才算)
text_sim = (
compute_text_similarity(current_text_content, existing_text_content)
if (current_text_content and existing_text_content)
else 0.0
)
# 计算结构相似度(有片段才算)
structure_sim = (
compute_structure_similarity(current_clips_data, existing_clips_data)
if (current_clips_data and existing_clips_data)
else 0.0
)
# 多维度融合:visual*0.5 + text*0.25 + structure*0.25
# 如果文案/结构数据缺失,只用视觉维度(visual 权重提升到 1.0)
if current_text_content and existing_text_content and current_clips_data and existing_clips_data:
dup_rate = (
visual_sim * VISUAL_WEIGHT + text_sim * TEXT_WEIGHT + structure_sim * STRUCTURE_WEIGHT
) * 100
else:
# 降级:只有视觉维度
dup_rate = visual_sim * 100
# 全片重复计数与 check_duplicate 判定口径一致
if ev["fusion"] >= DUPLICATE_THRESHOLD and (
@@ -1190,6 +1257,110 @@ class VideoDeduplicator:
}
# ── 文案 & 结构维度查重(Issue #P2-后端3) ────────────────────────
def _normalize_text(text: str) -> str:
"""文本标准化:去空白、转小写、去标点。"""
if not text:
return ""
# 去空白字符
text = re.sub(r"\s+", "", text)
# 转小写
text = text.lower()
# 去标点(只保留中文、字母、数字)
text = re.sub(r"[^\w\u4e00-\u9fff]", "", text)
return text
def compute_text_similarity(text1: str, text2: str) -> float:
"""计算两段文本的相似度(0~1)。
使用字符级 Jaccard 相似度交集 / 并集
适合短文本配音脚本的相似度比对
Args:
text1: 第一段文本
text2: 第二段文本
Returns:
0~1 之间的相似度
"""
t1 = _normalize_text(text1)
t2 = _normalize_text(text2)
if not t1 and not t2:
return 1.0 # 都为空,视为完全相同
if not t1 or not t2:
return 0.0 # 一个为空,完全不同
# 字符级 Jaccard
set1 = set(t1)
set2 = set(t2)
intersection = set1 & set2
union = set1 | set2
if not union:
return 0.0
return len(intersection) / len(union)
def compute_structure_similarity(clips1: list[dict], clips2: list[dict]) -> float:
"""计算两个视频的结构相似度(0~1)。
结构维度包括
1. 片段数差异数量越接近越相似
2. 片段类型序列相同位置的片段类型是否一致
3. 时长分布各片段时长占比是否相似
Args:
clips1: 第一个视频的片段列表每项包含 {clip_type, duration}
clips2: 第二个视频的片段列表
Returns:
0~1 之间的相似度
"""
if not clips1 and not clips2:
return 1.0
if not clips1 or not clips2:
return 0.0
# 1. 片段数相似度(数量差异越大越低)
n1, n2 = len(clips1), len(clips2)
count_sim = min(n1, n2) / max(n1, n2)
# 2. 类型序列相似度(逐位比较,相同位置类型是否一致)
min_len = min(n1, n2)
type_matches = sum(1 for i in range(min_len) if clips1[i].get("clip_type") == clips2[i].get("clip_type"))
type_sim = type_matches / min_len if min_len > 0 else 0.0
# 3. 时长分布相似度(归一化后比较分布)
total1 = sum(c.get("duration", 0) for c in clips1)
total2 = sum(c.get("duration", 0) for c in clips2)
if total1 > 0 and total2 > 0:
# 归一化为占比
dist1 = [c.get("duration", 0) / total1 for c in clips1]
dist2 = [c.get("duration", 0) / total2 for c in clips2]
# 比较前 min_len 个片段的占比差异(L1 距离转相似度)
l1_dist = sum(abs(dist1[i] - dist2[i]) for i in range(min_len))
# 加上多出的片段占比
if n1 > n2:
l1_dist += sum(dist1[i] for i in range(n2, n1))
elif n2 > n1:
l1_dist += sum(dist2[i] for i in range(n1, n2))
# L1 距离范围 [0, 2],转为相似度 [0, 1]
duration_sim = 1.0 - (l1_dist / 2.0)
else:
duration_sim = 0.0
# 三维度加权:数量 0.3 + 类型 0.4 + 时长 0.3
return count_sim * 0.3 + type_sim * 0.4 + duration_sim * 0.3
def _save_fingerprint_chunks(
fingerprint: VideoFingerprint,
video_id: str,
@@ -59,13 +59,18 @@ def build_xfade_filter_chain(
transitions: list[str],
*,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
transition_durations: list[float] | None = None,
jitters: list[float] | None = None,
output_label: str = "outv",
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链(re-export#1766 增加逐转场时长与 jitter 支持)."""
return _build_xfade_filter_chain_base(
clip_durations,
clip_video_labels,
transitions,
transition_duration=transition_duration,
transition_durations=transition_durations,
jitters=jitters,
output_label=output_label,
)
@@ -105,17 +105,24 @@ class TransitionEngine:
transitions: list[str],
*,
transition_duration: float | None = None,
transition_durations: list[float] | None = None,
jitters: list[float] | None = None,
output_label: str = "outv",
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链.
对每步转场应用验证和降级然后调用底层 ffmpeg_utils 构建
#1766 增强:支持逐转场独立时长(transition_durations)和位置微调(jitters)。
Args:
clip_durations: 每个片段的时长
clip_video_labels: 每个片段的视频流标签
transitions: 每个片段对应的转场效果
transition_duration: 统一转场时长None 则使用引擎默认值
transition_duration: 全局默认转场时长None 则使用引擎默认值
transition_durations: #1766 逐转场时长列表,与 transitions 等长;
None 时使用各 resolved config duration
jitters: #1766 逐转场位置偏移列表(秒)
output_label: 最终输出标签
Returns:
@@ -127,6 +134,8 @@ class TransitionEngine:
clip_video_labels=clip_video_labels,
transitions=transitions,
transition_duration=transition_duration or self._default_duration,
transition_durations=transition_durations,
jitters=jitters,
output_label=output_label,
)
@@ -134,17 +143,20 @@ class TransitionEngine:
resolved = self.resolve_clip_transitions(transitions, clip_durations)
resolved_effects = [c.effect for c in resolved]
# 使用统一的时长(取各转场中最大的时长作为基准,底层会做每步钳制
dur = transition_duration or self._default_duration
if not dur:
dur = max(c.duration for c in resolved) if resolved else DEFAULT_TRANSITION_DURATION
# #1766: 逐转场时长(优先使用传入的 transition_durations,否则用 resolved config
if transition_durations is not None:
resolved_durations = list(transition_durations)
else:
resolved_durations = [c.duration for c in resolved]
# 调用底层构建
return build_xfade_filter_chain(
clip_durations=clip_durations,
clip_video_labels=clip_video_labels,
transitions=resolved_effects,
transition_duration=dur,
transition_duration=transition_duration or self._default_duration,
transition_durations=resolved_durations,
jitters=jitters,
output_label=output_label,
)
@@ -1870,16 +1870,27 @@ class UnifiedRenderService:
)
else:
# 有转场效果:用 TransitionEngine 构建 xfade 链
layer_dur = 0.0
for d in layer_transition_durations:
if d > 0:
layer_dur = d
break
# #1766: 提取逐转场时长(每个 clip 的 transition_duration,跳过第一个)
# layer_transition_durations[i] 对应 clip i 的转场,第 0 个忽略
per_transition_durations = [d for idx, d in enumerate(layer_transition_durations) if idx > 0]
# #1766: 提取每个 clip 的 jitter(存在 config 中),跳过第一个
layer_jitters = [
(
all_clips[layer_clip_indices[idx]].config.get("transition_jitter", 0.0)
if isinstance(all_clips[layer_clip_indices[idx]].config, dict)
else 0.0
)
for idx in range(len(layer_clip_indices))
]
per_transition_jitters = [j for idx, j in enumerate(layer_jitters) if idx > 0]
xfade_filter, xfade_estimated_dur = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_duration=layer_dur if layer_dur > 0 else None,
transition_durations=(
per_transition_durations if any(d > 0 for d in per_transition_durations) else None
),
jitters=per_transition_jitters if any(j != 0.0 for j in per_transition_jitters) else None,
output_label=out_label,
)
if xfade_filter:
@@ -2227,12 +2238,17 @@ class UnifiedRenderService:
perturbation = (self.plan.config or {}).get("visual_perturbation") or {}
if not perturbation:
return {}
return {
result = {
"hflip": bool(perturbation.get("hflip", False)),
"zoom_ratio": max(1.0, min(1.2, float(perturbation.get("zoom_ratio", 1.0) or 1.0))),
"speed_factor": max(0.8, min(1.2, float(perturbation.get("speed_factor", 1.0) or 1.0))),
"brightness_shift": max(-30, min(30, int(perturbation.get("brightness_shift", 0) or 0))),
}
# #1765:同时读取像素级扰动滤镜
pixel_pert = (self.plan.config or {}).get("pixel_perturbation") or {}
if pixel_pert:
result["pixel_perturbation"] = pixel_pert
return result
def _apply_visual_perturbation_pre_scale(self, filters: list[str], perturbation: dict) -> None:
# scale+pad 之前的扰动(hflip),就地修改 filters
@@ -2251,6 +2267,48 @@ class UnifiedRenderService:
if brightness != 0:
filters.append(f"eq=brightness={brightness / 100.0:.3f}")
# #1765:追加像素级扰动滤镜
pixel_pert = perturbation.get("pixel_perturbation") or {}
if pixel_pert:
self._apply_pixel_perturbation(filters, pixel_pert)
def _apply_pixel_perturbation(self, filters: list[str], pixel_pert: dict) -> None:
"""应用像素级扰动滤镜(Issue #1765)。
滤镜参数幅度确保肉眼不可见SSIM > 0.95但能让同素材不同变体
在帧级产生 > 3% 的差异降低平台查重风险
"""
filter_list = pixel_pert.get("filters") or []
for filt in filter_list:
if filt == "noise":
# 轻微噪声:noise=alls=0.015:allf=t+u
strength = pixel_pert.get("noise_strength", 0.015)
filters.append(f"noise=alls={strength}:allf=t+u")
elif filt == "unsharp":
# 锐化/柔化:unsharp=3:3:amount
# amount > 0 锐化,< 0 柔化
amount = pixel_pert.get("unsharp_amount", 0.0)
if abs(amount) > 0.01:
filters.append(f"unsharp=3:3:{amount:.2f}")
elif filt == "curves":
# 对比度微调:curves 用 preset 或手动定义
# 简单方案:用 eq=contrast 代替(curves 语法复杂)
contrast = pixel_pert.get("curves_contrast", 1.0)
if abs(contrast - 1.0) > 0.01:
filters.append(f"eq=contrast={contrast:.3f}")
elif filt == "color_balance":
# RGB 通道偏移:color_balance=rs=...:gs=...:bs=...
r = pixel_pert.get("color_r", 0)
g = pixel_pert.get("color_g", 0)
b = pixel_pert.get("color_b", 0)
if r != 0 or g != 0 or b != 0:
# color_balance 参数范围 -1.0 ~ 1.0,这里用 /100 转换
filters.append(f"color_balance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}")
@staticmethod
def _clip_volume(clip: ResolvedClip) -> float:
"""获取 clip 的音量(config.volume)。缺省 1.0 原声,0.0 静音。"""
+16 -1
View File
@@ -2126,6 +2126,14 @@
"type": "JSON",
"unique": false
},
{
"index": false,
"name": "is_default",
"nullable": false,
"primary_key": false,
"type": "BOOLEAN",
"unique": false
},
{
"index": false,
"name": "created_at",
@@ -2142,6 +2150,13 @@
],
"name": "ix_projects_owner_user_id",
"unique": false
},
{
"columns": [
"owner_user_id"
],
"name": "uq_projects_owner_default",
"unique": true
}
],
"primary_key": [
@@ -3570,4 +3585,4 @@
]
}
}
}
}
@@ -35,14 +35,37 @@ class InMemoryAssetLibraryRepository:
return True
return False
def increment_asset_count(self, library_id: str, size_delta: int) -> None:
def increment_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
library = self._libraries.get(library_id)
if library:
library.asset_count += 1
library.asset_count += count_delta
library.total_size += size_delta
def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
def decrement_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
library = self._libraries.get(library_id)
if library:
library.asset_count = max(0, library.asset_count - 1)
library.asset_count = max(0, library.asset_count - count_delta)
library.total_size = max(0, library.total_size - size_delta)
def recount_assets(self, library_id: str) -> int:
"""InMemory 实现无法真正重算(没有 asset 数据源),返回当前计数。"""
library = self._libraries.get(library_id)
return library.asset_count if library else 0
def get_or_create_default_library(
self,
project_id: str,
kind: AssetLibraryKind,
*,
name: str | None = None,
) -> AssetLibrary:
"""幂等获取/创建默认素材库(Issue #1775,内存实现,模拟唯一约束语义)。"""
for lib in self._libraries.values():
if lib.project_id == project_id and lib.kind == kind:
return lib
# 回退到 find_by_project
for lib in self.find_by_project(project_id, kind):
return lib
library_name = name or f"{kind.value}素材库"
library = AssetLibrary.create(project_id=project_id, name=library_name, kind=kind)
return self.create(library)
@@ -29,3 +29,30 @@ class InMemoryProjectRepository:
del self._items[project_id]
return True
return False
def find_default_by_owner(self, owner_user_id: str) -> Project | None:
"""查找用户的默认项目(Issue #1775 幂等接口,内存实现)。"""
for p in self._items.values():
if p.owner_user_id == owner_user_id and getattr(p, "is_default", False):
return p
return None
def get_or_create_default_project(
self,
owner_user_id: str,
*,
name: str = "默认项目",
description: str = "小程序自动创建的默认项目",
) -> Project:
"""幂等获取/创建默认项目(内存实现,模拟 DB 部分唯一索引语义)。"""
existing = self.find_default_by_owner(owner_user_id)
if existing is not None:
return existing
project = Project.create(
owner_user_id=owner_user_id,
name=name,
description=description,
is_default=True,
)
self._items[project.id] = project
return project
@@ -77,16 +77,148 @@ class SQLAlchemyAssetLibraryRepository:
return True
return False
async def increment_asset_count(self, library_id: str, size_delta: int) -> None:
model = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).first()
if model:
model.asset_count = (model.asset_count or 0) + 1
model.total_size = (model.total_size or 0) + size_delta
self.session.commit()
def increment_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
"""原子递增素材计数(Issue #1776)。
async def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
model = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).first()
if model:
model.asset_count = max(0, (model.asset_count or 0) - 1)
model.total_size = max(0, (model.total_size or 0) - size_delta)
使用 SQL UPDATE 保证并发安全不单独 commit由调用方统一事务提交
"""
from sqlalchemy import func
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
{
AssetLibraryModel.asset_count: func.coalesce(AssetLibraryModel.asset_count, 0) + count_delta,
AssetLibraryModel.total_size: func.coalesce(AssetLibraryModel.total_size, 0) + size_delta,
}
)
def decrement_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
"""原子递减素材计数(Issue #1776),下限为 0 防止负数。
使用 SQL UPDATE 保证并发安全不单独 commit由调用方统一事务提交
使用 CASE WHEN 兼容 SQLite测试 PostgreSQL生产
"""
from sqlalchemy import case, func
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
{
AssetLibraryModel.asset_count: case(
(func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta < 0, 0),
else_=func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta,
),
AssetLibraryModel.total_size: case(
(func.coalesce(AssetLibraryModel.total_size, 0) - size_delta < 0, 0),
else_=func.coalesce(AssetLibraryModel.total_size, 0) - size_delta,
),
}
)
def recount_assets(self, library_id: str) -> int:
"""重算素材库计数(Issue #1776)。
直接查询实际素材数量排除已删除更新 asset_count total_size
返回重算后的实际计数
"""
from sqlalchemy import func
from packages.adapters.sqlalchemy_impl.models import AssetModel
# 查询实际计数(排除 deleted)
actual_count = (
self.session.query(func.count(AssetModel.id))
.filter(
AssetModel.asset_library_id == library_id,
AssetModel.status != "deleted",
)
.scalar()
or 0
)
# 查询实际总大小
actual_size = (
self.session.query(func.coalesce(func.sum(AssetModel.file_size), 0))
.filter(
AssetModel.asset_library_id == library_id,
AssetModel.status != "deleted",
)
.scalar()
or 0
)
# 更新素材库记录
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
{
AssetLibraryModel.asset_count: actual_count,
AssetLibraryModel.total_size: actual_size,
}
)
return actual_count
def get_or_create_default_library(
self,
project_id: str,
kind: AssetLibraryKind,
*,
name: str | None = None,
) -> AssetLibrary:
"""幂等获取/创建项目下指定 kind 的默认素材库(Issue #1775)。
依赖唯一约束 uq_asset_libraries_project_kind(project_id, kind)
并发创建只有一个成功其余 IntegrityError 后回滚重查
保证同一项目同 kind 永远只有一个素材库
"""
from sqlalchemy.exc import IntegrityError
default_names = {
AssetLibraryKind.VIDEO: "视频素材库",
AssetLibraryKind.VOICE: "配音素材库",
AssetLibraryKind.IMAGE: "图片素材库",
}
library_name = name or default_names.get(kind, f"{kind.value}素材库")
# 快速路径
existing = (
self.session.query(AssetLibraryModel)
.filter(AssetLibraryModel.project_id == project_id, AssetLibraryModel.kind == kind.value)
.first()
)
if existing:
return self._to_entity(existing)
library = AssetLibrary.create(project_id=project_id, name=library_name, kind=kind)
model = AssetLibraryModel(
id=library.id,
project_id=library.project_id,
name=library.name,
kind=library.kind.value,
asset_count=0,
total_size=0,
created_at=library.created_at,
updated_at=library.updated_at,
)
try:
self.session.add(model)
self.session.commit()
return library
except IntegrityError:
self.session.rollback()
existing = (
self.session.query(AssetLibraryModel)
.filter(
AssetLibraryModel.project_id == project_id,
AssetLibraryModel.kind == kind.value,
)
.first()
)
if existing:
return self._to_entity(existing)
raise
def _to_entity(self, model: AssetLibraryModel) -> AssetLibrary:
return AssetLibrary(
id=model.id,
project_id=model.project_id,
name=model.name,
kind=AssetLibraryKind(model.kind),
asset_count=int(model.asset_count or 0),
total_size=int(model.total_size or 0),
created_at=model.created_at,
updated_at=model.updated_at,
)
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetModel, AssetTagModel
from packages.adapters.sqlalchemy_impl.models import AssetLibraryModel, AssetModel, AssetTagModel
from packages.domain import Asset, AssetStatus, ClassificationStatus
@@ -141,6 +141,16 @@ class SQLAlchemyAssetRepository:
self.session.add(model)
self.session.flush()
self._sync_asset_tags(asset.id, asset.tag_ids)
# Issue #1776: 自动维护素材库计数(同事务内原子更新)
if asset.library_id and asset.status.value != "deleted":
from sqlalchemy import func
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == asset.library_id).update(
{
AssetLibraryModel.asset_count: func.coalesce(AssetLibraryModel.asset_count, 0) + 1,
AssetLibraryModel.total_size: func.coalesce(AssetLibraryModel.total_size, 0) + asset.file_size,
}
)
self.session.commit()
return asset
@@ -175,7 +185,27 @@ class SQLAlchemyAssetRepository:
def delete(self, asset_id: str) -> bool:
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model:
library_id = model.asset_library_id
file_size = model.file_size or 0
# 只统计非 deleted 状态的素材
was_counted = model.status != "deleted"
self.session.delete(model)
# Issue #1776: 自动维护素材库计数
if library_id and was_counted:
from sqlalchemy import case, func
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
{
AssetLibraryModel.asset_count: case(
(func.coalesce(AssetLibraryModel.asset_count, 0) - 1 < 0, 0),
else_=func.coalesce(AssetLibraryModel.asset_count, 0) - 1,
),
AssetLibraryModel.total_size: case(
(func.coalesce(AssetLibraryModel.total_size, 0) - file_size < 0, 0),
else_=func.coalesce(AssetLibraryModel.total_size, 0) - file_size,
),
}
)
self.session.commit()
return True
return False
@@ -187,11 +217,44 @@ class SQLAlchemyAssetRepository:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
# 先查询待删除素材的库分布(用于更新计数)
to_delete = (
self.session.query(AssetModel.asset_library_id, AssetModel.file_size)
.filter(AssetModel.id.in_(asset_ids), AssetModel.status != "deleted")
.all()
)
if not to_delete:
return 0
# 按库分组统计
library_deltas: dict[str, tuple[int, int]] = {} # library_id -> (count_delta, size_delta)
for lib_id, size in to_delete:
if lib_id not in library_deltas:
library_deltas[lib_id] = (0, 0)
c, s = library_deltas[lib_id]
library_deltas[lib_id] = (c + 1, s + (size or 0))
# 执行软删除
count = (
self.session.query(AssetModel)
.filter(AssetModel.id.in_(asset_ids), AssetModel.status != "deleted")
.update({AssetModel.status: "deleted", AssetModel.updated_at: now}, synchronize_session=False)
)
# Issue #1776: 自动维护各素材库计数
if library_deltas:
from sqlalchemy import case, func
for lib_id, (count_delta, size_delta) in library_deltas.items():
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == lib_id).update(
{
AssetLibraryModel.asset_count: case(
(func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta < 0, 0),
else_=func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta,
),
AssetLibraryModel.total_size: case(
(func.coalesce(AssetLibraryModel.total_size, 0) - size_delta < 0, 0),
else_=func.coalesce(AssetLibraryModel.total_size, 0) - size_delta,
),
}
)
self.session.commit()
return count
+87 -1
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Index, Integer, String, Text, UniqueConstraint, text
from sqlalchemy.orm import declarative_base
Base: Any = declarative_base()
@@ -44,12 +44,18 @@ class UserModel(Base):
class ProjectModel(Base):
__tablename__ = "projects"
__table_args__ = (
# Issue #1775: 每个用户至多一个默认项目(部分唯一索引,只约束 is_default=true 的行)。
# 注意:不加 UniqueConstraint(那会要求全表唯一),用部分索引表达"每用户一个默认项目"。
Index("uq_projects_owner_default", "owner_user_id", unique=True, postgresql_where=text("is_default = true")),
)
id = Column(String(36), primary_key=True)
owner_user_id = Column(String(36), nullable=False, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=False, default="")
shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表
is_default = Column(Boolean, nullable=False, default=False, server_default="false")
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -647,3 +653,83 @@ class VideoFingerprintChunkModel(Base):
color_histogram = Column(JSON, nullable=False)
frame_count = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class ScriptModel(Base):
"""口播文案库 (Issue #1795)"""
__tablename__ = "scripts"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False, default="")
segments = Column(JSON, nullable=False, default=list)
tags = Column(JSON, nullable=False, default=list)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
class LipsyncJobModel(Base):
"""对口型任务 ORM 模型 — #1796 MediaKit 对口型.
记录用户提交的对口型任务跟踪 MediaKit 异步任务状态
"""
__tablename__ = "lipsync_jobs"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
project_id = Column(String(36), nullable=False, default="", index=True)
# 输入参数
video_url = Column(Text, nullable=False)
audio_url = Column(Text, nullable=False)
enable_video_loop = Column(Boolean, nullable=False, default=False)
# MediaKit 任务状态
mediakit_task_id = Column(String(200), nullable=False, default="", index=True)
status = Column(
String(20), nullable=False, default="pending", index=True
) # pending → submitted → processing → completed → failed
output_video_url = Column(Text, nullable=False, default="")
output_duration = Column(Float, nullable=False, default=0.0)
error_message = Column(Text, nullable=False, default="")
error_code = Column(String(100), nullable=False, default="")
# 时间戳
submitted_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class AiAvatarRenderJob(Base):
"""AI数字人渲染任务 — #1798"""
__tablename__ = "ai_avatar_render_jobs"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
project_id = Column(String(36), nullable=False, default="", index=True)
# 输入参数
lipsync_job_id = Column(String(36), nullable=False)
script_id = Column(String(36), nullable=False)
b_roll_segments = Column(JSON, nullable=False, default=list)
# b_roll_segments 格式: [{"script_segment_index": 0, "asset_url": "...", "mode": "fullscreen|pip", "start_time": 5.0, "end_time": 10.0}, ...]
title_config = Column(JSON, nullable=False, default=dict)
cover_config = Column(JSON, nullable=False, default=dict)
# 任务状态
status = Column(String(20), nullable=False, default="pending", index=True)
progress = Column(Integer, nullable=False, default=0)
output_video_url = Column(Text, nullable=False, default="")
output_cover_url = Column(Text, nullable=False, default="")
output_duration = Column(Float, nullable=False, default=0.0)
error_message = Column(Text, nullable=False, default="")
submitted_at = Column(DateTime, nullable=True)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -15,6 +15,7 @@ class SQLAlchemyProjectRepository:
name=model.name,
description=model.description,
shared_users=model.shared_users or [],
is_default=bool(getattr(model, "is_default", False)),
created_at=model.created_at,
)
@@ -33,9 +34,12 @@ class SQLAlchemyProjectRepository:
name=project.name,
description=project.description,
shared_users=project.shared_users,
is_default=project.is_default,
created_at=project.created_at,
)
self.session.add(model)
if existing:
existing.is_default = project.is_default
self.session.commit()
return project
@@ -76,3 +80,59 @@ class SQLAlchemyProjectRepository:
self.session.delete(model)
self.session.commit()
return True
def find_default_by_owner(self, owner_user_id: str) -> Project | None:
"""查找用户的默认项目(is_default=true)。"""
model = (
self.session.query(ProjectModel)
.filter(ProjectModel.owner_user_id == owner_user_id, ProjectModel.is_default.is_(True))
.first()
)
return self._to_entity(model) if model else None
def get_or_create_default_project(
self,
owner_user_id: str,
*,
name: str = "默认项目",
description: str = "小程序自动创建的默认项目",
) -> Project:
"""幂等获取/创建用户的默认项目(Issue #1775)。
依赖部分唯一索引 uq_projects_owner_default每用户至多一条 is_default=true
并发创建时只有一个 INSERT 成功其余触发 IntegrityError 后回滚重查
保证同一用户永远只有一个默认项目
"""
from sqlalchemy.exc import IntegrityError
# 快速路径:已有默认项目
existing = self.find_default_by_owner(owner_user_id)
if existing is not None:
return existing
project = Project.create(
owner_user_id=owner_user_id,
name=name,
description=description,
is_default=True,
)
model = ProjectModel(
id=project.id,
owner_user_id=project.owner_user_id,
name=project.name,
description=project.description,
shared_users=project.shared_users,
is_default=True,
created_at=project.created_at,
)
try:
self.session.add(model)
self.session.commit()
return project
except IntegrityError:
# 并发:另一个请求已插入默认项目,回滚后重查
self.session.rollback()
existing = self.find_default_by_owner(owner_user_id)
if existing is not None:
return existing
raise
@@ -6,7 +6,10 @@ from typing import List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel
from packages.adapters.sqlalchemy_impl.models import (
TemplateClipConfigModel,
TemplateModel,
)
from packages.domain.template_clip_config import (
ClipType,
TemplateClipConfig,
@@ -38,6 +41,24 @@ class SQLAlchemyTemplateClipConfigRepository:
models = query.offset(skip).limit(limit).all()
return [self._model_to_entity(m) for m in models]
def template_owned_by(self, template_id: str, user_id: str) -> bool:
"""校验旧模板主表 ``templates`` 中模板归属当前用户且未删除(is_active=True.
片段配置主表 ``template_clip_configs`` 本身没有 user_id
归属关系通过模板主表 ``templates.user_id`` 确定
新表 ``edit_templates`` 为全局模板库 user_id 不走此校验
"""
return (
self.session.query(TemplateModel.id)
.filter(
TemplateModel.id == template_id,
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
.first()
is not None
)
def get(self, config_id: str) -> Optional[TemplateClipConfig]:
"""根据 ID 获取配置"""
model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config_id).first()
@@ -10,6 +10,7 @@ from __future__ import annotations
import uuid
from typing import List, Optional
from sqlalchemy import or_
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import (
@@ -28,6 +29,20 @@ class SQLAlchemyTemplateRepository:
def __init__(self, session: Session) -> None:
self.session = session
def _filter_with_segment_configs(self, query):
"""只保留在 template_clip_configs 或 template_segments 中存在片段配置的模板。
两张表都没有记录的模板无法用于生成from-assets 400
剪辑页选模板时应排除模板编辑器不传 valid_only仍可见全部模板
"""
has_clip_config = self.session.query(TemplateClipConfigModel.id).filter(
TemplateClipConfigModel.template_id == TemplateModel.id,
)
has_segment = self.session.query(TemplateSegmentModel.id).filter(
TemplateSegmentModel.template_id == TemplateModel.id,
)
return query.filter(or_(has_clip_config.exists(), has_segment.exists()))
# ── Template CRUD ──
def list_by_user(
@@ -40,11 +55,14 @@ class SQLAlchemyTemplateRepository:
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> List[Template]:
query = self.session.query(TemplateModel).filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
if valid_only:
query = self._filter_with_segment_configs(query)
if category:
query = query.filter(TemplateModel.category == category)
if mode:
@@ -102,6 +120,27 @@ class SQLAlchemyTemplateRepository:
template.segments = self.list_segments(template.id)
return template
def get_active(self, template_id: str, user_id: str) -> Optional[Template]:
"""获取归属当前用户且未删除(is_active=True)的模板,否则返回 None.
用于编辑器访问门禁模板不存在已软删除或不属于当前用户时返回 None
由调用方映射为 404 :meth:`get` 的区别是额外过滤 is_active
"""
model = (
self.session.query(TemplateModel)
.filter(
TemplateModel.id == template_id,
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
.first()
)
if model is None:
return None
template = self._model_to_entity(model)
template.segments = self.list_segments(template.id)
return template
def create(self, template: Template) -> Template:
model = TemplateModel(
id=template.id,
@@ -173,11 +212,14 @@ class SQLAlchemyTemplateRepository:
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> int:
query = self.session.query(TemplateModel).filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
if valid_only:
query = self._filter_with_segment_configs(query)
if category:
query = query.filter(TemplateModel.category == category)
if mode:
@@ -62,6 +62,7 @@ class ListTemplatesFilter:
tag: Optional[str] = None
keyword: Optional[str] = None
mode: Optional[str] = None
valid_only: bool = False
@dataclass
@@ -106,6 +106,7 @@ class ListTemplatesUseCase:
tag=filter.tag,
keyword=filter.keyword,
mode=filter.mode,
valid_only=filter.valid_only,
)
@@ -127,6 +128,7 @@ class CountTemplatesUseCase:
tag=filter.tag,
keyword=filter.keyword,
mode=filter.mode,
valid_only=filter.valid_only,
)
+314
View File
@@ -0,0 +1,314 @@
"""BGM 池差异化分配 — 打破变体间音频指纹一致性 (Issue #1767).
三层递进策略
1. **BGM 池分配核心**维护风格匹配的 BGM 每个变体基于 variant_seed
随机分配一首不同 BGM保证变体间音频指纹不同
2. **段落差异化池不够时的补充**同一首 BGM 做差异化裁剪不同变体使用
不同起始点/段落进一步降低音频相似度
3. **音量微调**不同变体 BGM 音量 ±3dB 微调混音比例有微小差异
约束
- 不破坏现有单视频 BGM 选择逻辑单视频不走池分配
- BGM 情绪/风格与视频内容匹配基于源 plan BGM style 做风格筛选
- 分配可复现 seed 同结果
"""
from __future__ import annotations
import logging
import random
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
# ── BGM 池条目 ──────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class BGMPoolEntry:
"""BGM 池条目"""
id: str
preset_id: str # 关联 PRESET_BGM_LIBRARY 中的 ID(用于渲染侧解析音频路径)
mood: str # 情绪/风格:upbeat / relax / tech / commerce / emotional / cinematic
duration: float # 时长(秒)
audio_url: str = "" # CDN/OSS 直链(优先级高于 preset_id
tags: list[str] = field(default_factory=list)
# ── BGM 池(10 首,覆盖 6 种风格) ──────────────────────────────────────────
BGM_POOL: list[BGMPoolEntry] = [
# upbeat (轻快)
BGMPoolEntry(
id="pool_upbeat_001", preset_id="bgm_upbeat_001", mood="upbeat", duration=120.0, tags=["轻快", "阳光", "vlog"]
),
BGMPoolEntry(
id="pool_upbeat_002", preset_id="bgm_upbeat_002", mood="upbeat", duration=95.0, tags=["轻快", "电子", "运动"]
),
BGMPoolEntry(
id="pool_upbeat_003", preset_id="bgm_upbeat_003", mood="upbeat", duration=110.0, tags=["轻快", "夏日", "旅行"]
),
# relax (治愈)
BGMPoolEntry(
id="pool_relax_001", preset_id="bgm_relax_001", mood="relax", duration=180.0, tags=["治愈", "钢琴", "冥想"]
),
BGMPoolEntry(
id="pool_relax_002", preset_id="bgm_relax_002", mood="relax", duration=150.0, tags=["治愈", "自然", "放松"]
),
BGMPoolEntry(
id="pool_relax_003", preset_id="bgm_relax_003", mood="relax", duration=200.0, tags=["治愈", "古典", "钢琴"]
),
# tech (科技)
BGMPoolEntry(
id="pool_tech_001", preset_id="bgm_tech_001", mood="tech", duration=85.0, tags=["科技", "电子", "数码"]
),
BGMPoolEntry(
id="pool_tech_002", preset_id="bgm_tech_002", mood="tech", duration=100.0, tags=["科技", "极简", "AI"]
),
# commerce (电商)
BGMPoolEntry(
id="pool_commerce_001",
preset_id="bgm_commerce_001",
mood="commerce",
duration=75.0,
tags=["电商", "时尚", "带货"],
),
BGMPoolEntry(
id="pool_commerce_002",
preset_id="bgm_commerce_002",
mood="commerce",
duration=90.0,
tags=["电商", "品牌", "品质"],
),
]
# ── 风格 → 情绪映射 ────────────────────────────────────────────────────────
# preset_bgm.py 中 style 字段 → bgm_pool.py 中 mood 字段
STYLE_TO_MOOD: dict[str, str] = {
"upbeat": "upbeat",
"relax": "relax",
"tech": "tech",
"commerce": "commerce",
"emotional": "emotional",
"cinematic": "cinematic",
}
# ── 策略一:BGM 池分配 ─────────────────────────────────────────────────────
def get_bgm_pool_candidates(source_mood: str | None = None) -> list[BGMPoolEntry]:
"""获取 BGM 池候选列表。
如果指定了 source_mood优先返回同 mood 的条目
如果同 mood 条目不足 2 降级返回全池保证有足够候选
Args:
source_mood: BGM 的情绪/风格来自 preset_bgm.py style 字段
Returns:
候选 BGM 列表至少 2 个条目
"""
if not source_mood:
return list(BGM_POOL)
mood = STYLE_TO_MOOD.get(source_mood, source_mood)
matched = [e for e in BGM_POOL if e.mood == mood]
# 同 mood 至少要有 2 首,否则无法"差异化",降级全池
if len(matched) >= 2:
return matched
return list(BGM_POOL)
def select_bgm_from_pool(
variant_seed: int,
candidates: list[BGMPoolEntry] | None = None,
) -> BGMPoolEntry:
"""基于 variant_seed 从候选池中选一首 BGM(可复现)。
Args:
variant_seed: 变体随机种子
candidates: 候选池None 时使用全池
Returns:
选中的 BGM 条目
"""
pool = candidates if candidates is not None else list(BGM_POOL)
if not pool:
pool = list(BGM_POOL)
rng = random.Random(variant_seed)
return rng.choice(pool)
# ── 策略二:段落差异化 ─────────────────────────────────────────────────────
def generate_bgm_segment_offset(variant_seed: int, bgm_duration: float) -> float:
"""为变体生成 BGM 段落起始偏移(策略二)。
不同变体从同一首 BGM 的不同位置开始播放进一步降低音频指纹相似度
偏移范围 [0, max_offset]max_offset = min(30s, bgm_duration * 0.3)
量化到 5 秒整数倍便于复现和调试
Args:
variant_seed: 变体随机种子
bgm_duration: BGM 总时长
Returns:
起始偏移0 ~ max_offset 之间5s 步长
"""
rng = random.Random(variant_seed + 7919) # 加素数偏移,避免与 BGM 选择 seed 序列重合
max_offset = min(30.0, bgm_duration * 0.3)
steps = int(max_offset // 5.0)
if steps <= 0:
return 0.0
return float(rng.randint(0, steps) * 5)
# ── 策略三:音量微调 ─────────────────────────────────────────────────────
def generate_bgm_volume_adjust(variant_seed: int) -> float:
"""为变体生成 BGM 音量微调值(策略三)。
±3dB 微调让不同变体的 BGM/配音混音比例有微小差异
离散步长-3, -2, -1, 0, 1, 2, 3 dB
Args:
variant_seed: 变体随机种子
Returns:
音量调整值dB-3.0 ~ 3.0
"""
rng = random.Random(variant_seed + 104729) # 另一个素数偏移
return float(rng.choice([-3, -2, -1, 0, 1, 2, 3]))
# ── 批量分配入口 ──────────────────────────────────────────────────────────
def allocate_bgm_pool_for_variants(
source_bgm_config: dict,
variant_seeds: list[int],
) -> list[dict]:
"""为批量变体分配不同的 BGM 池配置。
整合三层策略池分配 + 段落偏移 + 音量微调
每个变体得到一个 dict可直接合并到 plan.config["bgm"]
Args:
source_bgm_config: plan BGM 配置用于风格匹配
variant_seeds: 每个变体的随机种子列表
Returns:
每个变体的 BGM 池配置 dict 列表 variant_seeds 等长
每项包含 preset_id / audio_url / audio_offset / volume_adjust_db
如果源 BGM 未启用返回空列表
"""
if not source_bgm_config or not source_bgm_config.get("enabled", False):
return []
if not variant_seeds:
return []
# 从源 BGM 配置中推断风格
source_mood = _infer_source_mood(source_bgm_config)
# 策略一:获取候选池
candidates = get_bgm_pool_candidates(source_mood)
# 为每个变体分配不同的 BGM(尽量不重复)
assignments = _assign_unique_bgm(candidates, variant_seeds)
results = []
for i, (entry, seed) in enumerate(zip(assignments, variant_seeds, strict=False)):
# 策略二:段落偏移
offset = generate_bgm_segment_offset(seed, entry.duration)
# 策略三:音量微调
volume_adj = generate_bgm_volume_adjust(seed)
result = {
"preset_id": entry.preset_id,
"audio_url": entry.audio_url,
"audio_offset": offset,
"volume_adjust_db": volume_adj,
"bgm_pool_entry_id": entry.id,
"bgm_pool_mood": entry.mood,
}
results.append(result)
logger.info(
"变体 %d BGM 池分配: seed=%d bgm=%s mood=%s offset=%.1fs vol_adj=%+.0fdB",
i,
seed,
entry.id,
entry.mood,
offset,
volume_adj,
)
return results
def _infer_source_mood(source_bgm_config: dict) -> str | None:
"""从源 BGM 配置推断风格/情绪。
优先级
1. preset_id preset_bgm 库获取 style
2. bgm_pool_mood 上游已设置过二次分配场景
3. 无法推断 None返回全池候选
"""
preset_id = source_bgm_config.get("preset_id", "")
if preset_id:
from packages.domain.preset_bgm import get_preset_bgm
preset = get_preset_bgm(preset_id)
if preset:
return STYLE_TO_MOOD.get(preset.style, preset.style)
# 如果之前已经分配过 BGM 池,直接用 mood
pool_mood = source_bgm_config.get("bgm_pool_mood", "")
if pool_mood:
return pool_mood
return None
def _assign_unique_bgm(
candidates: list[BGMPoolEntry],
variant_seeds: list[int],
) -> list[BGMPoolEntry]:
"""尽量让每个变体选到不同的 BGM。
策略 seed BGM如果与前面变体重复用递增 seed 重试
如果候选池大小 < 变体数允许重复但不连续
"""
if not candidates or not variant_seeds:
return []
assignments: list[BGMPoolEntry] = []
used_ids: set[str] = set()
for i, seed in enumerate(variant_seeds):
rng = random.Random(seed)
# 先尝试选一个没用过的
chosen = None
for _attempt in range(len(candidates)):
candidate = rng.choice(candidates)
if candidate.id not in used_ids:
chosen = candidate
break
if chosen is None:
# 候选池已用完,允许重复但取下一个(循环)
idx = i % len(candidates)
chosen = candidates[idx]
assignments.append(chosen)
used_ids.add(chosen.id)
return assignments
+3 -1
View File
@@ -70,10 +70,11 @@ class Project:
name: str
description: str = ""
shared_users: list[str] = field(default_factory=list) # 被共享的用户 ID 列表
is_default: bool = False # 是否为用户的默认项目(小程序自动创建),DB 部分唯一索引保证每人至多一个
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@classmethod
def create(cls, owner_user_id: str, name: str, description: str = "") -> "Project":
def create(cls, owner_user_id: str, name: str, description: str = "", is_default: bool = False) -> "Project":
clean_name = name.strip()
if not clean_name:
raise ValueError("项目名称不能为空")
@@ -83,6 +84,7 @@ class Project:
name=clean_name,
description=description.strip(),
shared_users=[],
is_default=is_default,
)
def is_owner(self, user_id: str) -> bool:
+177
View File
@@ -0,0 +1,177 @@
"""转场位置与类型随机化(Issue #1766.
为不同变体生成不同的转场序列类型 + 时长 + 位置微调
打破"所有变体转场节奏完全一致"的结构相似性
降低平台查重识别为结构相似视频的风险
设计要点
1. 转场类型池5 种效果dissolve / zoom / slideleft / wipeleft / fade
2. 硬切概率保证 30%-50% 的转场是硬切保持节奏感
3. 转场时长随机0.3s ~ 0.8s
4. 转场位置微调±0.5s 偏移通过 xfade jitter 实现
5. #1764 节奏模板协同:长片段之间的转场倾向于更长时长
"""
from __future__ import annotations
import logging
import random
logger = logging.getLogger(__name__)
# ── 转场类型池 ──────────────────────────────────────────────────────────────
#: 非硬切转场类型池(5 种效果,均为 FFmpeg xfade 支持的 transition 名称)
#: 选择标准:视觉效果差异大、FFmpeg 渲染稳定、肉眼可区分
TRANSITION_POOL: list[str] = [
"dissolve", # 溶解
"zoomin", # 缩放(放大进入)
"slideleft", # 左滑入
"wipeleft", # 左擦除
"fade", # 淡入淡出
]
#: 硬切(无转场效果),由 build_xfade_filter_chain 特殊处理(concat filter
HARD_CUT = "cut"
# ── 时长约束 ────────────────────────────────────────────────────────────────
#: 随机转场时长下限(秒)
TRANSITION_DURATION_MIN = 0.3
#: 随机转场时长上限(秒)
TRANSITION_DURATION_MAX = 0.8
# ── 硬切比例 ────────────────────────────────────────────────────────────────
#: 硬切概率下限(至少 30% 硬切,保持节奏感)
CUT_RATIO_MIN = 0.3
#: 硬切概率上限(最多 50% 硬切,保证足够视觉变化)
CUT_RATIO_MAX = 0.5
# ── 位置微调 ────────────────────────────────────────────────────────────────
#: 转场位置最大偏移(秒),实际偏移在 [-MAX, +MAX] 均匀分布
#: 正 = 转场推迟(多留一点前一片段),负 = 转场提前
TIMING_JITTER_MAX = 0.5
# ── 内部辅助 ────────────────────────────────────────────────────────────────
def _cut_probability_for_pair(
prev_duration: float,
next_duration: float,
) -> float:
"""根据相邻片段时长计算硬切概率。
#1764 节奏模板协同:
- 两片段都较短<3s快节奏 硬切概率更高节奏更紧凑
- 两片段都较长>6s慢节奏 硬切概率稍低留出过渡空间
- 混合场景 基准概率CUT_RATIO_MIN + CUT_RATIO_MAX/ 2
返回概率始终在 [CUT_RATIO_MIN, CUT_RATIO_MAX] 范围内
"""
base = (CUT_RATIO_MIN + CUT_RATIO_MAX) / 2 # 0.4
avg_dur = (prev_duration + next_duration) / 2
if avg_dur < 3.0:
# 快节奏:硬切概率偏高
return min(CUT_RATIO_MAX, base + 0.1)
elif avg_dur > 6.0:
# 慢节奏:硬切概率偏低(更多视觉过渡)
return max(CUT_RATIO_MIN, base - 0.1)
return base
def _apply_jitter(
base_duration: float,
jitter: float,
clip_duration: float,
) -> float:
"""给转场时长应用微调偏移,钳制到安全范围。
Args:
base_duration: 基础转场时长
jitter: 偏移量可正可负
clip_duration: 较短的相邻片段时长转场不能超过此值
Returns:
钳制后的实际转场时长
"""
effective = base_duration + jitter
# 上界:不超过相邻片段时长的 40%(留足内容时间),也不超过 MAX
upper = min(TRANSITION_DURATION_MAX, clip_duration * 0.4)
lower = TRANSITION_DURATION_MIN if jitter < 0 else max(TRANSITION_DURATION_MIN, base_duration)
return max(lower, min(upper, effective))
# ── 核心函数 ────────────────────────────────────────────────────────────────
def generate_transition_plan(
num_transitions: int,
*,
clip_durations: list[float] | None = None,
rng: random.Random | None = None,
) -> list[dict]:
"""为变体生成一组随机化的转场计划。
每个转场点独立随机选择类型和时长硬切比例保持在 30%-50%
Args:
num_transitions: 转场点数量= 主片段数 - 1
clip_durations: 各片段时长用于协同节奏长片段间转场更长
长度应 >= num_transitions + 1不足时用默认值
rng: 可选随机数生成器测试可注入固定种子
Returns:
转场计划列表每项
- effect: str "cut" TRANSITION_POOL 中某一效果
- duration: float 转场时长cut 0.0
- jitter: float 位置偏移量-0.5 ~ +0.5
"""
if num_transitions <= 0:
return []
if rng is None:
rng = random.Random()
if clip_durations is None:
clip_durations = [5.0] * (num_transitions + 1)
plan: list[dict] = []
for i in range(num_transitions):
prev_dur = clip_durations[i] if i < len(clip_durations) else 5.0
next_dur = clip_durations[i + 1] if (i + 1) < len(clip_durations) else 5.0
# 计算硬切概率(协同节奏)
cut_prob = _cut_probability_for_pair(prev_dur, next_dur)
# 随机决定是否硬切
if rng.random() < cut_prob:
effect = HARD_CUT
duration = 0.0
else:
effect = rng.choice(TRANSITION_POOL)
base_dur = rng.uniform(TRANSITION_DURATION_MIN, TRANSITION_DURATION_MAX)
# 协同节奏:长片段间转场基础时长更长
avg_dur = (prev_dur + next_dur) / 2
if avg_dur > 6.0:
base_dur = min(TRANSITION_DURATION_MAX, base_dur * 1.15)
# 应用位置微调
jitter = rng.uniform(-TIMING_JITTER_MAX, TIMING_JITTER_MAX)
shorter_clip = min(prev_dur, next_dur)
duration = _apply_jitter(base_dur, jitter, shorter_clip)
jitter_val = rng.uniform(-TIMING_JITTER_MAX, TIMING_JITTER_MAX) if effect != HARD_CUT else 0.0
plan.append(
{
"effect": effect,
"duration": round(duration, 3),
"jitter": round(jitter_val, 3),
}
)
return plan
+109 -2
View File
@@ -29,6 +29,7 @@ import logging
import random
from packages.domain.plan_generator_utils import _resolve_start_time
from packages.domain.transition_randomizer import generate_transition_plan
logger = logging.getLogger(__name__)
@@ -129,7 +130,10 @@ def reselect_clips_for_variant(
Raises:
ValueError: 源片段为空 / 素材池为空 / 素材时长全为 0无法差异化选片
"""
rng = rng or random.Random()
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
if not source_clips:
raise ValueError("源 plan 无片段,无法为变体重新选片")
if not candidate_asset_ids:
@@ -222,6 +226,9 @@ def reselect_clips_for_variant(
batch_segments.setdefault(aid, []).append(interval)
result[idx] = _base_clip_data(src, asset_id=aid, start=start, duration=target_dur)
# ── 4. #1766 转场随机化:为相邻 main 片段对生成随机转场序列 ────────
_apply_transition_randomization(result, rng)
return [c for c in result if c is not None]
@@ -312,7 +319,10 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
- speed_factor: 0.95~1.05 速度微调±5%肉眼不太敏感但时间轴不同
- brightness_shift: -10~+10 亮度偏移eq=brightness画面明暗差异
"""
rng = rng or random.Random()
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
return {
"hflip": rng.random() < 0.3,
"zoom_ratio": round(1.0 + rng.uniform(0, 0.08), 4),
@@ -321,6 +331,103 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
}
def generate_pixel_perturbation(rng: random.Random | int | None = None) -> dict:
"""为一个变体生成像素级扰动滤镜参数(Issue #1765)。
在现有视觉扰动hflip/zoom/brightness基础上额外叠加 2-3
像素级滤镜让同素材不同变体在帧级 SSIM 差异 > 3%肉眼看不出差异
滤镜选项随机选 2-3 种叠加
- noise: 轻微噪声 (noise=alls=0.015:allf=t+u)
- unsharp: 锐化或柔化 (unsharp=3:3:-0.5 ~ 3:3:0.5)
- curves: 对比度微调 (curves 轻微调整)
- color_balance: RGB 通道偏移 (color_balance 微调)
返回 dict可直接存入 plan.config["pixel_perturbation"]
渲染侧读取后追加到 ffmpeg filter chain
"""
if rng is None:
rng = random.Random()
elif isinstance(rng, int):
rng = random.Random(rng)
# 可用滤镜池
filter_options = ["noise", "unsharp", "curves", "color_balance"]
# 随机选 2-3 种
num_filters = rng.choice([2, 2, 3])
selected = rng.sample(filter_options, num_filters)
result: dict = {"filters": selected}
# 为每种滤镜生成具体参数
if "noise" in selected:
# 噪声强度 0.01~0.02(肉眼不可见)
result["noise_strength"] = round(rng.uniform(0.01, 0.02), 4)
if "unsharp" in selected:
# 锐化/柔化:-0.5 ~ +0.5(正值锐化,负值柔化)
result["unsharp_amount"] = round(rng.uniform(-0.5, 0.5), 2)
if "curves" in selected:
# 对比度微调:0.95 ~ 1.05
result["curves_contrast"] = round(rng.uniform(0.95, 1.05), 3)
if "color_balance" in selected:
# RGB 通道偏移:-5 ~ +5(极轻微色偏)
result["color_r"] = rng.choice([-5, -3, 0, 0, 3, 5])
result["color_g"] = rng.choice([-5, -3, 0, 0, 3, 5])
result["color_b"] = rng.choice([-5, -3, 0, 0, 3, 5])
return result
def _apply_transition_randomization(
result: list[dict | None],
rng: random.Random,
) -> None:
"""#1766 对 result 中相邻 main 片段应用转场随机化(就地修改)。
为每对相邻 main 片段独立选择
- 转场类型TRANSITION_POOL 中随机或硬切
- 转场时长0.3s ~ 0.8s协同片段时长
- 位置微调 jitter±0.5s存入 config["transition_jitter"]
硬切比例保证在 30%-50%intro/outro 等非 main 片段的转场保持源值不变
"""
# 收集 main 片段的索引(按 order 排序)
main_indices = [i for i, c in enumerate(result) if c is not None and c.get("clip_type", "main") == "main"]
if len(main_indices) < 2:
# 不足 2 个 main 片段,无转场点可随机化
return
num_transitions = len(main_indices) - 1
# 用 main 片段的 duration 作为协同节奏的输入
clip_durations = [result[i]["duration"] for i in main_indices]
plan = generate_transition_plan(
num_transitions,
clip_durations=clip_durations,
rng=rng,
)
# 将转场计划应用到每对相邻 main 片段
# plan[k] 是 main_indices[k] → main_indices[k+1] 之间的转场
# 转场信息存储在"目标 clip"(即每对的第二个)的 transition_effect/duration
for k, transition_info in enumerate(plan):
target_idx = main_indices[k + 1]
if result[target_idx] is None:
continue
clip = result[target_idx]
clip["transition_effect"] = transition_info["effect"]
clip["transition_duration"] = transition_info["duration"]
# jitter 存入 config,供渲染侧 xfade_builder 读取
cfg = clip.get("config") or {}
cfg["transition_jitter"] = transition_info["jitter"]
clip["config"] = cfg
def _base_clip_data(src: dict, *, asset_id: str, start: float, duration: float | None = None) -> dict:
"""从源片段构造落库 dict(保留骨架/转场/文案/速度,替换素材与起点)。"""
return {
+359 -1
View File
@@ -14,7 +14,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from packages.domain.template_clip_config import TransitionEffect
@@ -373,3 +373,361 @@ def _append_audio_concat(parts: list[str], clip_chains: list[ClipFilterChain]) -
# concat 滤镜(使用 audio_label 作为输入)
audio_inputs = "".join(f"[{c.audio_label}]" for c in audio_chains)
parts.append(f"{audio_inputs}concat=n={len(audio_chains)}:v=0:a=1[outa]")
# ── 标题 drawtext 滤镜构建(#1789)─────────────────────────────────────────────
# drawtext 字体搜索路径:按优先级列出常见安装位置
# 服务器使用 Noto Sans SC(思源黑体)作为默认字体
DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf",
"/usr/share/fonts/noto/NotoSansSC-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
# 前端字体名 → drawtext 字体搜索关键字
DRAWTEXT_FONT_MAP: dict[str, str] = {
"思源黑体": "NotoSansCJK",
"思源宋体": "NotoSerifCJK",
"苹方": "NotoSansCJK",
"PingFang": "NotoSansCJK",
"微软雅黑": "NotoSansCJK",
"楷体": "NotoSerifCJK",
"华康俪金黑": "NotoSansCJK",
}
def _escape_drawtext_text(text: str) -> str:
"""转义 drawtext 特殊字符。
FFmpeg drawtext 要求转义
- \\ \\\\
- '\\\\'
- : \\\\:
- % %%drawtext % 是时间码特殊字符
"""
result = text.replace("\\", "\\\\\\\\")
result = result.replace("'", "\\\\'")
result = result.replace(":", "\\\\:")
result = result.replace("%", "%%")
return result
def _resolve_font_path(font_name: str) -> str:
"""解析字体名到服务器实际字体文件路径。
查找策略
1. 通过 DRAWTEXT_FONT_MAP 映射前端字体名到服务器关键字
2. DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径
3. 未找到则返回空字符串drawtext 使用内置默认字体
"""
keyword = DRAWTEXT_FONT_MAP.get(font_name, font_name)
import os
for path in DRAWTEXT_FONT_SEARCH_PATHS:
if keyword.lower() in path.lower() and os.path.isfile(path):
return path
# fallback:遍历搜索任意可用字体
for path in DRAWTEXT_FONT_SEARCH_PATHS:
if os.path.isfile(path):
return path
return ""
def build_title_drawtext_filter(
title_config: dict[str, Any],
output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT,
) -> str | None:
"""从 title_config 生成 FFmpeg drawtext 滤镜字符串。
支持前端 TitleSettings 的全部参数
- text / 标题文字
- font / 字体名
- font_size / 字号
- font_color / 颜色#RRGGBB
- position / 位置top / center / bottom / custom
- bold / 粗体
- stroke / 描边
- shadow / 阴影
- pos_x, pos_y / 自由位置坐标
Args:
title_config: 标题配置 dict来自 plan.config["title"]
output_width: 输出视频宽度
output_height: 输出视频高度
Returns:
drawtext 滤镜字符串标题为空或 disabled 时返回 None
"""
if not title_config or not isinstance(title_config, dict):
return None
# 字段名归一化:兼容 content/text、font_preset/font 两套命名
text = (title_config.get("text") or title_config.get("content") or "").strip()
if not text:
return None
enabled = title_config.get("enabled", True)
if not enabled:
return None
# ── 样式参数 ──
font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体"
font_size = int(title_config.get("font_size") or title_config.get("size") or 36)
font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff"
# 去掉 # 前缀(drawtext 用纯 hex 或颜色名)
if font_color.startswith("#"):
font_color = font_color[1:]
position = title_config.get("position", "top")
bold = bool(title_config.get("bold", True))
stroke = title_config.get("stroke")
shadow = title_config.get("shadow")
# ── 构建 drawtext 参数 ──
params: list[str] = []
# 字体文件
font_path = _resolve_font_path(font_name)
if font_path:
escaped_path = font_path.replace("\\", "\\\\").replace(":", "\\\\:").replace("'", "\\\\'")
params.append(f"fontfile='{escaped_path}'")
# 文字内容
params.append(f"text='{_escape_drawtext_text(text)}'")
# 字号 & 颜色
params.append(f"fontsize={font_size}")
params.append(f"fontcolor={font_color}")
# 粗体:bold 在 drawtext 中通过 font 的 Bold 变体实现
# 若字体有 Bold 变体可用 fontfont=bold;否则通过 borderw 模拟
if bold:
# 使用 font 参数尝试加载 Bold 变体(Noto Sans SC 有 Bold 变体文件)
params.append("font=bold")
# 描边(borderw 需要 libfreetype 支持)
if stroke:
if isinstance(stroke, bool):
border_width = 2
border_color = "black"
elif isinstance(stroke, dict):
border_width = int(stroke.get("width", 2)) if stroke.get("enabled", True) else 0
border_color = (stroke.get("color") or "#000000").lstrip("#")
else:
border_width = 0
border_color = "black"
if border_width > 0:
params.append(f"borderw={border_width}")
params.append(f"bordercolor={border_color}")
# 阴影(shadowcolor + shadowx/y
if shadow:
if isinstance(shadow, bool):
params.append("shadowcolor=black")
params.append("shadowx=2")
params.append("shadowy=2")
elif isinstance(shadow, dict):
if shadow.get("enabled", True):
params.append(f"shadowcolor={(shadow.get('color') or '#000000').lstrip('#')}")
params.append(f"shadowx={int(shadow.get('offset_x', 2))}")
params.append(f"shadowy={int(shadow.get('offset_y', 2))}")
# ── 位置计算 ──
# 优先使用自定义坐标 pos_x / pos_y
pos_x = title_config.get("pos_x")
pos_y = title_config.get("pos_y")
if (
position == "custom"
and isinstance(pos_x, (int, float))
and isinstance(pos_y, (int, float))
and not isinstance(pos_x, bool)
and not isinstance(pos_y, bool)
):
params.append(f"x={int(pos_x)}")
params.append(f"y={int(pos_y)}")
else:
# 三档预设位置:top / center / bottom
# x 始终水平居中:(w-text_w)/2
params.append("x=(w-text_w)/2")
if position == "center":
params.append("y=(h-text_h)/2")
elif position == "bottom":
params.append("y=h-text_h-50")
else:
# top(默认)
params.append("y=50")
return "drawtext=" + ":".join(params)
# ── B-roll 叠加滤镜 ─────────────────────────────────────────────────────────
def build_broll_overlay_filter(
b_roll_segments: list[dict[str, Any]],
video_duration: float,
output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT,
) -> str:
"""构建 B-roll 叠加滤镜链。
支持两种模式:
- fullscreen: 在对口型视频中按时间段替换为全屏 B-roll 画面
- pip: 在对口型视频上叠加画中画 B-roll
Args:
b_roll_segments: B-roll 片段配置列表
video_duration: 对口型视频总时长()
output_width: 输出宽度
output_height: 输出高度
Returns:
FFmpeg filter_complex 滤镜字符串片段
"""
if not b_roll_segments:
return ""
parts: list[str] = []
sorted_segments = sorted(b_roll_segments, key=lambda s: s.get("start_time", 0))
# 按模式分组处理
fullscreen_segments = [s for s in sorted_segments if s.get("mode") == "fullscreen"]
pip_segments = [s for s in sorted_segments if s.get("mode") == "pip"]
# ── fullscreen 模式: 切分 + concat ──
if fullscreen_segments:
parts.append(_build_fullscreen_filters(fullscreen_segments, video_duration, output_width, output_height))
# ── pip 模式: overlay 滤镜 ──
if pip_segments:
for idx, seg in enumerate(pip_segments):
start = seg.get("start_time", 0)
end = seg.get("end_time", video_duration)
scale = seg.get("pip_scale", 0.3)
position = seg.get("pip_position", "bottom_right")
pip_w = int(output_width * scale)
pip_h = int(output_height * scale)
# 位置映射
pos_map = {
"top_left": "10:10",
"top_right": "W-w-10:10",
"bottom_left": "10:H-h-10",
"bottom_right": "W-w-10:H-h-10",
"center": "(W-w)/2:(H-h)/2",
}
pos_expr = pos_map.get(position, pos_map["bottom_right"])
broll_input_idx = len(sorted_segments) # placeholder for input index
parts.append(
f"[{broll_input_idx + idx}:v]scale={pip_w}:{pip_h}," f"enable='between(t,{start},{end})'[pip{idx}];"
)
# overlay onto main stream
if idx == 0:
base_label = "[vout]" if fullscreen_segments else "[0:v]"
else:
base_label = f"[pip{idx - 1}]"
parts.append(f"{base_label}[pip{idx}]overlay={pos_expr}:enable='between(t,{start},{end})'[vout{idx}];")
result = "".join(parts)
# 清理末尾多余分号
if result.endswith(";"):
result = result[:-1]
return result
def _build_fullscreen_filters(
segments: list[dict[str, Any]],
video_duration: float,
output_width: int,
output_height: int,
) -> str:
"""构建 fullscreen 模式的切分 + concat 滤镜.
将对口型视频按 B-roll 时间段切分然后用 concat 拼接 B-roll 片段
"""
parts: list[str] = []
prev_end = 0.0
for idx, seg in enumerate(segments):
start = seg.get("start_time", 0)
end = seg.get("end_time", video_duration)
# 保持原视频片段(B-roll 之前的部分)
if prev_end < start:
parts.append(f"[0:v]trim=start={prev_end}:end={start},setpts=PTS-STARTPTS[main{idx}];")
# B-roll 片段:缩放至目标分辨率
parts.append(
f"[{idx + 1}:v]scale={output_width}:{output_height}"
f":force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2,"
f"trim=start=0:end={end - start},setpts=PTS-STARTPTS[br{idx}];"
)
prev_end = end
# 尾部片段
if prev_end < video_duration:
last_idx = len(segments)
parts.append(f"[0:v]trim=start={prev_end}:end={video_duration},setpts=PTS-STARTPTS[main{last_idx}];")
# concat 所有片段
segment_labels = []
for idx in range(len(segments)):
start = segments[idx].get("start_time", 0)
if (idx == 0 and segments[0].get("start_time", 0) > 0) or idx > 0:
prev_end_prev = segments[idx - 1].get("end_time", 0) if idx > 0 else 0
if prev_end_prev < start:
segment_labels.append(f"[main{idx}]")
segment_labels.append(f"[br{idx}]")
if prev_end < video_duration:
segment_labels.append(f"[main{len(segments)}]")
n = len(segment_labels)
if n > 0:
concat_inputs = "".join(segment_labels)
parts.append(f"{concat_inputs}concat=n={n}:v=1:a=0[vout];")
return "".join(parts)
def build_cover_extract_command(
cover_config: dict[str, Any],
output_path: str,
) -> str:
"""根据封面配置生成 FFmpeg 截帧命令。
Args:
cover_config: 封面配置支持:
- timestamp: 截取时间点()默认 0
- width: 封面宽度(可选)
- height: 封面高度(可选)
output_path: 输出封面文件路径
Returns:
FFmpeg 命令行字符串
"""
if not cover_config or not isinstance(cover_config, dict):
timestamp = 0.0
else:
timestamp = cover_config.get("timestamp", 0.0)
width = cover_config.get("width", 0) if isinstance(cover_config, dict) else 0
height = cover_config.get("height", 0) if isinstance(cover_config, dict) else 0
scale_filter = ""
if width > 0 and height > 0:
scale_filter = (
f"-vf scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
)
cmd = f"ffmpeg -ss {timestamp} -i INPUT_VIDEO -frames:v 1 {scale_filter} -y {output_path}"
return cmd
+148 -7
View File
@@ -1,4 +1,4 @@
"""配音时长 → 片段时长分配纯函数(#1749)。
"""配音时长 → 片段时长分配纯函数(#1749 + #1764 节奏模板)。
定稿规则工单 #1749):
1. 片段数 = 模板片段数定死不因素材增减
@@ -8,6 +8,14 @@
禁止慢放禁止截断配音
4. 任何情况下不得因素材时长/数量报错打断用户
#1764 节奏模板 + #1768 多样化增强:
- 预设 8 种权重序列不同变体用不同节奏模板
- 片段时长 = 配音总时长 × 该片段权重 / 权重总和
- 平均分配作为权重全 1 的特例保留
- 每个片段 >= MIN_CLIP_DURATION2
- #1768:最大片段时长 <= 素材可用时长 × 90%
- #1768:成片总时长与配音时长误差 <= TOTAL_DURATION_TOLERANCE0.5s
本模块为纯函数输入片段骨架每段转场效果/时长与配音总时长
输出每段目标时长target duration与成片总时长不碰 DB不碰素材
"""
@@ -15,16 +23,73 @@
from __future__ import annotations
import logging
import random
from typing import Optional
logger = logging.getLogger(__name__)
#: 单段最小时长(秒):低于此值播放器/渲染链路易出问题
MIN_CLIP_DURATION = 1.0
MIN_CLIP_DURATION = 2.0
#: 成片总时长与配音时长的可接受误差(秒)
TOTAL_DURATION_TOLERANCE = 0.5
# ── #1764 节奏模板池 ──────────────────────────────────────────────────────
# 每种模板是权重序列,权重值代表相对时长比例
# 变体基于 variant_seed 随机选一个模板,实现不同变体时长结构不同
RHYTHM_TEMPLATES: list[list[int]] = [
[1, 1, 1, 1, 1], # 平均(基准)
[2, 1, 3, 1, 2], # 中间长,两端短
[1, 2, 1, 2, 1], # 偶数段长
[3, 1, 1, 1, 3], # 两端长,中间短
[1, 1, 3, 2, 1], # 后段渐长
[2, 1, 1, 3, 1], # 前段较长 + 第4段最长
[2, 2, 1, 1, 2], # #1768 前重后轻
[3, 2, 1, 2, 1], # #1768 渐弱节奏
]
def get_rhythm_template(variant_seed: int | None = None) -> list[int]:
"""根据 variant_seed 选择一个节奏模板。
Args:
variant_seed: 变体随机种子None 时返回平均模板
Returns:
权重序列list[int]
"""
if variant_seed is None:
return RHYTHM_TEMPLATES[0] # 默认平均
rng = random.Random(variant_seed)
return rng.choice(RHYTHM_TEMPLATES)
def adapt_template_length(template: list[int], clip_count: int) -> list[int]:
"""将节奏模板适配到实际片段数。
片段数 != 模板长度时
- clip_count < len(template): 截断
- clip_count > len(template): 循环填充
Args:
template: 原始权重序列
clip_count: 实际片段数
Returns:
适配后的权重序列长度 == clip_count
"""
if clip_count <= 0:
return []
if clip_count == len(template):
return template[:]
if clip_count < len(template):
return template[:clip_count]
# clip_count > len(template): 循环填充
result = []
for i in range(clip_count):
result.append(template[i % len(template)])
return result
def transition_overlap_seconds(transition_effect: Optional[str], transition_duration: float) -> float:
"""转场导致的相邻片段重叠时长。
@@ -43,9 +108,13 @@ def plan_clip_durations(
voice_duration: float,
transition_effects: Optional[list[Optional[str]]] = None,
transition_durations: Optional[list[float]] = None,
rhythm_template: Optional[list[int]] = None,
asset_durations: Optional[list[float]] = None,
) -> list[float]:
"""把配音总时长分配到 clip_count 段,返回每段目标时长(秒)。
#1764:支持节奏模板,按权重比例分配时长;无模板时平均分配(向后兼容)。
分配口径Σ段长 Σ转场重叠 = 配音时长成片净时长 = 配音
转场重叠发生在相邻片段之间 clip_count-1 i 处重叠取
**后一段i+1** 的转场设置 xfade 构建口径一致转场挂在后段
@@ -58,6 +127,9 @@ def plan_clip_durations(
transition_effects: 每段转场效果长度 clip_countindex 0 的转场无效
transition_durations: 每段转场时长长度 clip_count
asset_durations: #1768 每段可用素材时长(秒),用于钳制最大片段时长
<= 素材可用时长 × 90%长度 clip_countNone 或空则不钳制上限
Returns:
每段目标时长列表长度 clip_count无配音/非法输入返回 []
"""
@@ -92,13 +164,82 @@ def plan_clip_durations(
MIN_CLIP_DURATION,
)
per_clip = gross / clip_count
result = [round(per_clip, 3) for _ in range(clip_count)]
# 末段吸收舍入误差:直接用 gross - 前段之和
result[-1] = round(gross - sum(result[:-1]), 3)
# #1764:按节奏模板权重分配(无模板时全 1 = 平均分配)
weights = rhythm_template if rhythm_template and len(rhythm_template) == clip_count else [1] * clip_count
# 确保每个片段 >= MIN_CLIP_DURATION
# 先按权重分配,再检查最小值
total_weight = sum(weights)
raw_durations = [(w / total_weight) * gross for w in weights]
# 保底检查:如果有片段 < MIN_CLIP_DURATION,提升它并从最长片段扣
result = [round(d, 3) for d in raw_durations]
for _ in range(3): # 最多迭代 3 次
min_idx = min(range(len(result)), key=lambda i: result[i])
if result[min_idx] >= MIN_CLIP_DURATION:
break
# 从最长片段借时长
max_idx = max(range(len(result)), key=lambda i: result[i])
if max_idx == min_idx or result[max_idx] <= MIN_CLIP_DURATION:
# 无法再调整,强制保底
result[min_idx] = MIN_CLIP_DURATION
break
deficit = MIN_CLIP_DURATION - result[min_idx]
result[min_idx] = MIN_CLIP_DURATION
result[max_idx] = round(result[max_idx] - deficit, 3)
# #1768:最大片段时长钳制(<= 素材可用时长 × 90%)
if asset_durations and len(asset_durations) == clip_count:
for _ in range(3): # 迭代收敛
clamped = False
for i in range(len(result)):
try:
max_dur = float(asset_durations[i]) * 0.9
except (TypeError, ValueError, IndexError):
continue
if result[i] > max_dur and max_dur >= MIN_CLIP_DURATION:
excess = result[i] - max_dur
result[i] = round(max_dur, 3)
# 将多余时长分配给最短的未超限片段
candidates = [
j
for j in range(len(result))
if j != i
and (
not asset_durations
or j >= len(asset_durations)
or result[j] < float(asset_durations[j]) * 0.9
)
]
if candidates:
shortest = min(candidates, key=lambda j: result[j])
result[shortest] = round(result[shortest] + excess, 3)
clamped = True
if not clamped:
break
# 末段吸收舍入误差
total_assigned = sum(result[:-1])
result[-1] = round(gross - total_assigned, 3)
if result[-1] < MIN_CLIP_DURATION:
# 极端情况下末段被舍入压得过小,摊平
result[-1] = MIN_CLIP_DURATION
# #1768:时长总和误差校验(成片净时长 ≈ 配音时长)
net_total = total_output_duration(result, transition_effects, transition_durations)
deviation = abs(net_total - voice)
if deviation > TOTAL_DURATION_TOLERANCE:
logger.warning(
"#1768 时长总和误差 %.3fs 超过阈值 %.1fsvoice=%.2fs, net=%.2fs),末段补偿修正",
deviation,
TOTAL_DURATION_TOLERANCE,
voice,
net_total,
)
# 修正末段使净时长回归配音时长
result[-1] = round(result[-1] + (voice - net_total), 3)
if result[-1] < MIN_CLIP_DURATION:
result[-1] = MIN_CLIP_DURATION
return result
+31 -8
View File
@@ -109,6 +109,8 @@ def build_xfade_filter_chain(
transitions: list[str],
*,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
transition_durations: list[float] | None = None,
jitters: list[float] | None = None,
output_label: str = "outv",
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链.
@@ -116,11 +118,19 @@ def build_xfade_filter_chain(
对每步 xfade 自动钳制 transition duration确保
``offset + td first_input_duration``避免 FFmpeg exit 234
#1766 增强:支持逐转场独立时长(transition_durations)和位置微调(jitters)。
传入 transition_durations 每个转场点使用各自的时长而非全局统一值
jitters 用于在 offset 上做 ±N 秒微调实现转场位置随机化
Args:
clip_durations: 每个片段的时长必须与 trim 后的实际时长一致
clip_video_labels: 每个片段的视频流标签 "v0", "v1"
transitions: 每个片段对应的转场效果第一个片段的转场被忽略
transition_duration: 转场时长
transition_duration: 全局默认转场时长transition_durations 缺失时 fallback
transition_durations: #1766 逐转场时长列表(与 transitions 等长),
i 项对应 transitions[i] 的时长None 时使用 transition_duration
jitters: #1766 逐转场位置偏移列表(秒),与 transitions 等长,
正值推迟转场负值提前转场None 时不做微调
output_label: 最终输出标签
Returns:
@@ -150,15 +160,28 @@ def build_xfade_filter_chain(
else:
first_input_dur = cumulative - total_transition
# 正确的 offset 计算:offset 应相对于累积输出时长
# offset = 累积输出中,转场开始的时间点
# = first_input_dur - transition_duration
# 这样每个转场之间的"纯内容"时长等于原始 clip 时长
offset = max(0.0, first_input_dur - transition_duration)
# #1766: 逐转场时长(优先)或全局默认
step_duration = (
transition_durations[i - 1]
if transition_durations and (i - 1) < len(transition_durations)
else transition_duration
)
# 安全钳制:offset + td 不能超过第一个输入的时长
# #1766: 位置微调 jitter
jitter = jitters[i - 1] if jitters and (i - 1) < len(jitters) else 0.0
# offset = 转场开始点(相对于累积输出起点)
# 基础 offset = first_input_dur - step_duration
# jitter > 0 推迟转场(offset 增大);jitter < 0 提前转场(offset 减小)
offset = max(0.0, first_input_dur - step_duration) + jitter
# 安全钳制:offset 不能超出可用范围
max_offset = max(0.0, first_input_dur - 0.001)
offset = max(0.0, min(offset, max_offset))
# 安全钳制 td:offset + td 不能超过第一个输入的时长
available = max(0.0, first_input_dur - offset)
safe_td = min(transition_duration, available)
safe_td = min(step_duration, available)
# 同时不能超过剩余总时长
remaining = max(0.0, sum(clip_durations) - cumulative)
+9 -2
View File
@@ -30,9 +30,16 @@ class AssetLibraryRepository(ABC):
pass
@abstractmethod
async def increment_asset_count(self, library_id: str, size_delta: int) -> None:
def increment_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
"""原子递增素材计数(Issue #1776)。"""
pass
@abstractmethod
async def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
def decrement_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
"""原子递减素材计数(Issue #1776)。"""
pass
@abstractmethod
def recount_assets(self, library_id: str) -> int:
"""重算素材库计数(Issue #1776)。"""
pass
+2
View File
@@ -18,6 +18,7 @@ class TemplateRepositoryPort(Protocol):
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> List[Template]: ...
def get(self, template_id: str, user_id: str) -> Optional[Template]: ...
def create(self, template: Template) -> Template: ...
@@ -31,6 +32,7 @@ class TemplateRepositoryPort(Protocol):
tag: Optional[str] = None,
keyword: Optional[str] = None,
mode: Optional[str] = None,
valid_only: bool = False,
) -> int: ...
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template: ...
def list_segments(self, template_id: str) -> List[TemplateSegment]: ...
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""素材库计数重算脚本(Issue #1776)。
用法:
# Dry-run: 输出差异清单,不执行修改
python scripts/recount_asset_counts.py --dry-run
# 执行修正
python scripts/recount_asset_counts.py
# 只处理指定项目
python scripts/recount_asset_counts.py --project-id <project_id>
# 只处理指定素材库
python scripts/recount_asset_counts.py --library-id <library_id>
"""
import argparse
import sys
from pathlib import Path
# 添加项目根目录到 path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import create_engine, func
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetLibraryModel, AssetModel
def get_db_session() -> Session:
"""创建数据库 session。"""
import os
database_url = os.getenv("DATABASE_URL")
if not database_url:
print("ERROR: DATABASE_URL environment variable not set")
sys.exit(1)
engine = create_engine(database_url)
return Session(engine)
def check_discrepancies(session: Session, project_id: str | None = None, library_id: str | None = None) -> list[dict]:
"""检查素材库计数差异。
返回列表每项包含:
- library_id: 素材库 ID
- library_name: 素材库名称
- recorded_count: 记录的计数
- actual_count: 实际计数
- delta: 差异 (actual - recorded)
"""
query = session.query(AssetLibraryModel)
if project_id:
query = query.filter(AssetLibraryModel.project_id == project_id)
if library_id:
query = query.filter(AssetLibraryModel.id == library_id)
libraries = query.all()
discrepancies = []
for lib in libraries:
# 查询实际计数(排除 deleted)
actual_count = (
session.query(func.count(AssetModel.id))
.filter(
AssetModel.asset_library_id == lib.id,
AssetModel.status != "deleted",
)
.scalar()
or 0
)
actual_size = (
session.query(func.coalesce(func.sum(AssetModel.file_size), 0))
.filter(
AssetModel.asset_library_id == lib.id,
AssetModel.status != "deleted",
)
.scalar()
or 0
)
recorded_count = int(lib.asset_count or 0)
recorded_size = int(lib.total_size or 0)
if actual_count != recorded_count or actual_size != recorded_size:
discrepancies.append(
{
"library_id": lib.id,
"library_name": lib.name,
"project_id": lib.project_id,
"kind": lib.kind,
"recorded_count": recorded_count,
"actual_count": actual_count,
"count_delta": actual_count - recorded_count,
"recorded_size": recorded_size,
"actual_size": actual_size,
"size_delta": actual_size - recorded_size,
}
)
return discrepancies
def fix_discrepancies(session: Session, discrepancies: list[dict]) -> int:
"""修正素材库计数。返回修正数量。"""
fixed = 0
for d in discrepancies:
session.query(AssetLibraryModel).filter(AssetLibraryModel.id == d["library_id"]).update(
{
AssetLibraryModel.asset_count: d["actual_count"],
AssetLibraryModel.total_size: d["actual_size"],
}
)
fixed += 1
session.commit()
return fixed
def main():
parser = argparse.ArgumentParser(description="素材库计数重算脚本(Issue #1776)")
parser.add_argument("--dry-run", action="store_true", help="只输出差异清单,不执行修正")
parser.add_argument("--project-id", type=str, help="只处理指定项目")
parser.add_argument("--library-id", type=str, help="只处理指定素材库")
args = parser.parse_args()
session = get_db_session()
try:
discrepancies = check_discrepancies(session, args.project_id, args.library_id)
if not discrepancies:
print("✅ 所有素材库计数一致,无需修正")
return
# 输出差异清单
print(f"发现 {len(discrepancies)} 个素材库计数不一致:\n")
print(f"{'Library ID':<40} {'Name':<20} {'Recorded':<10} {'Actual':<10} {'Delta':<10}")
print("-" * 90)
for d in discrepancies:
print(
f"{d['library_id']:<40} {d['library_name'][:20]:<20} {d['recorded_count']:<10} {d['actual_count']:<10} {d['count_delta']:+<10}"
)
total_delta = sum(d["count_delta"] for d in discrepancies)
print(f"\n总计差异: {total_delta:+d}")
if args.dry_run:
print("\n[DRY-RUN] 未执行修正。移除 --dry-run 参数以执行修正。")
else:
print("\n正在执行修正...")
fixed = fix_discrepancies(session, discrepancies)
print(f"✅ 已修正 {fixed} 个素材库计数")
finally:
session.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,317 @@
"""#1768 节奏模板多样化增强 — 单元测试。
覆盖
- 8 种预设模板完整性
- MIN_CLIP_DURATION = 2.0修复旧 1.0 覆盖 bug
- 最大片段时长钳制<= 素材可用时长 × 90%
- 时长总和误差校验<= 0.5s
- asset_durations 参数向后兼容None/ = 不钳制
"""
from __future__ import annotations
import pytest
from packages.domain.voice_duration_planner import (
MIN_CLIP_DURATION,
RHYTHM_TEMPLATES,
TOTAL_DURATION_TOLERANCE,
adapt_template_length,
get_rhythm_template,
plan_clip_durations,
total_output_duration,
)
# ── 模板池 ──────────────────────────────────────────────────────────────────
class TestRhythmTemplatesPool:
"""#1768 模板池扩展到 8 种。"""
def test_template_count_is_8(self):
assert len(RHYTHM_TEMPLATES) == 8
def test_all_templates_have_5_segments(self):
for tpl in RHYTHM_TEMPLATES:
assert len(tpl) == 5
def test_new_template_22112_exists(self):
assert [2, 2, 1, 1, 2] in RHYTHM_TEMPLATES
def test_new_template_32121_exists(self):
assert [3, 2, 1, 2, 1] in RHYTHM_TEMPLATES
def test_original_6_templates_preserved(self):
originals = [
[1, 1, 1, 1, 1],
[2, 1, 3, 1, 2],
[1, 2, 1, 2, 1],
[3, 1, 1, 1, 3],
[1, 1, 3, 2, 1],
[2, 1, 1, 3, 1],
]
for orig in originals:
assert orig in RHYTHM_TEMPLATES
def test_all_weights_positive(self):
for tpl in RHYTHM_TEMPLATES:
assert all(w > 0 for w in tpl)
def test_weight_sum_variety(self):
"""不同模板权重和应不完全相同,确保节奏有差异。"""
sums = {sum(t) for t in RHYTHM_TEMPLATES}
assert len(sums) >= 3 # 至少有 3 种不同的权重和
# ── 常量修复 ─────────────────────────────────────────────────────────────────
class TestConstantsFixed:
"""#1768 修复 MIN_CLIP_DURATION 从 1.0 回到 2.0。"""
def test_min_clip_duration_is_2(self):
assert MIN_CLIP_DURATION == 2.0
def test_total_duration_tolerance_is_05(self):
assert TOTAL_DURATION_TOLERANCE == 0.5
# ── get_rhythm_template ─────────────────────────────────────────────────────
class TestGetRhythmTemplate:
def test_none_seed_returns_average(self):
assert get_rhythm_template(None) == [1, 1, 1, 1, 1]
def test_same_seed_returns_same_template(self):
for seed in [0, 42, 999, 123456]:
t1 = get_rhythm_template(seed)
t2 = get_rhythm_template(seed)
assert t1 == t2
def test_different_seeds_can_yield_different_templates(self):
"""大量 seed 应能命中多个不同模板。"""
results = {tuple(get_rhythm_template(s)) for s in range(200)}
assert len(results) >= 5 # 200 个 seed 至少命中 5 种模板
# ── adapt_template_length ────────────────────────────────────────────────────
class TestAdaptTemplateLength:
def test_exact_match(self):
tpl = [2, 2, 1, 1, 2]
assert adapt_template_length(tpl, 5) == tpl
def test_truncate(self):
tpl = [2, 2, 1, 1, 2]
assert adapt_template_length(tpl, 3) == [2, 2, 1]
def test_extend_cycles(self):
tpl = [2, 2, 1, 1, 2]
result = adapt_template_length(tpl, 8)
assert len(result) == 8
assert result == [2, 2, 1, 1, 2, 2, 2, 1]
def test_zero_clips(self):
assert adapt_template_length([1, 1, 1], 0) == []
def test_negative_clips(self):
assert adapt_template_length([1, 1, 1], -1) == []
# ── plan_clip_durations 基础行为 ────────────────────────────────────────────
class TestPlanClipDurationsBasic:
def test_invalid_inputs(self):
assert plan_clip_durations(0, 30.0) == []
assert plan_clip_durations(-1, 30.0) == []
assert plan_clip_durations(5, 0.0) == []
assert plan_clip_durations(5, -10.0) == []
assert plan_clip_durations(5, "abc") == []
def test_average_distribution_no_transitions(self):
result = plan_clip_durations(5, 30.0)
assert len(result) == 5
assert abs(sum(result) - 30.0) < 0.01
def test_all_segments_above_min(self):
result = plan_clip_durations(5, 30.0, rhythm_template=[3, 1, 1, 1, 3])
for dur in result:
assert dur >= MIN_CLIP_DURATION
def test_with_rhythm_template(self):
tpl = [2, 2, 1, 1, 2]
result = plan_clip_durations(5, 30.0, rhythm_template=tpl)
assert len(result) == 5
# 权重和 = 8,每段应大致为 7.5, 7.5, 3.75, 3.75, 7.5
assert result[0] > result[2] # 权重 2 > 权重 1
assert abs(sum(result) - 30.0) < 0.5
def test_total_duration_matches_voice(self):
"""成片净时长 ≈ 配音时长(无转场时完全等于)。"""
for voice in [15.0, 30.0, 60.0, 120.0]:
result = plan_clip_durations(5, voice)
net = total_output_duration(result)
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE
def test_with_transitions(self):
"""有转场时成片净时长也应 ≈ 配音时长。"""
effects = [None, "xfade", "xfade", "xfade", "xfade"]
durations = [0.0, 1.0, 1.0, 1.0, 1.0]
result = plan_clip_durations(5, 30.0, transition_effects=effects, transition_durations=durations)
net = total_output_duration(result, effects, durations)
assert abs(net - 30.0) <= TOTAL_DURATION_TOLERANCE
# ── #1768 最小片段时长钳制 ────────────────────────────────────────────────────
class TestMinClipDurationClamp:
def test_min_duration_2s_enforced(self):
"""极端权重下,所有片段仍 >= 2.0s。"""
tpl = [10, 1, 1, 1, 1]
result = plan_clip_durations(5, 20.0, rhythm_template=tpl)
for dur in result:
assert dur >= 2.0, f"片段时长 {dur} < MIN_CLIP_DURATION(2.0)"
def test_short_voice_still_meets_minimum(self):
"""配音极短时保底每段 MIN_CLIP_DURATION。"""
result = plan_clip_durations(5, 3.0)
for dur in result:
assert dur >= MIN_CLIP_DURATION
# ── #1768 最大片段时长钳制 ────────────────────────────────────────────────────
class TestMaxClipDurationClamp:
def test_no_clamp_without_asset_durations(self):
"""不传 asset_durations 时不做上限钳制(向后兼容)。"""
tpl = [5, 1, 1, 1, 1]
result = plan_clip_durations(5, 30.0, rhythm_template=tpl)
# 第一段权重 5/9 * 30 = 16.67,不应被钳制
assert result[0] > 10.0
def test_no_clamp_with_empty_asset_durations(self):
"""asset_durations 为空列表时不做上限钳制。"""
tpl = [5, 1, 1, 1, 1]
result = plan_clip_durations(5, 30.0, rhythm_template=tpl, asset_durations=[])
assert result[0] > 10.0
def test_clamp_respects_90_percent(self):
"""有素材时长时,片段时长 <= 素材可用时长 × 90%"""
tpl = [5, 1, 1, 1, 1]
# 素材只有第一段短(12s),90% = 10.8s
asset_durs = [12.0, 60.0, 60.0, 60.0, 60.0]
result = plan_clip_durations(5, 30.0, rhythm_template=tpl, asset_durations=asset_durs)
max_allowed = 12.0 * 0.9
assert result[0] <= max_allowed + 0.01, f"第一段 {result[0]} 超过 90% 上限 {max_allowed}"
def test_clamp_does_not_violate_min(self):
"""素材极短时钳制不违反 MIN_CLIP_DURATION。"""
# 素材 2.0s90% = 1.8s < MIN(2.0),不应钳制到 1.8
asset_durs = [2.0, 60.0, 60.0, 60.0, 60.0]
result = plan_clip_durations(5, 30.0, asset_durations=asset_durs)
for dur in result:
assert dur >= MIN_CLIP_DURATION
def test_clamp_preserves_total(self):
"""钳制后总时长仍应接近配音时长。"""
asset_durs = [10.0, 60.0, 60.0, 60.0, 60.0]
voice = 30.0
result = plan_clip_durations(5, voice, asset_durations=asset_durs)
net = total_output_duration(result)
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE + 0.5 # 允许略多误差
def test_all_assets_short(self):
"""所有素材都短时,钳制全部生效但不违反最小值。"""
asset_durs = [8.0, 8.0, 8.0, 8.0, 8.0]
result = plan_clip_durations(5, 30.0, asset_durations=asset_durs)
for dur in result:
assert dur >= MIN_CLIP_DURATION
max_allowed = 8.0 * 0.9
# 如果 max_allowed >= MIN_CLIP_DURATION 才钳制
if max_allowed >= MIN_CLIP_DURATION:
assert dur <= max_allowed + 0.1
# ── #1768 时长总和误差校验 ────────────────────────────────────────────────────
class TestTotalDurationTolerance:
def test_no_transition_exact_match(self):
"""无转场时总时长精确等于配音。"""
result = plan_clip_durations(5, 25.0)
assert abs(sum(result) - 25.0) < 0.01
def test_with_transition_within_tolerance(self):
"""有转场时净时长在 0.5s 以内。"""
effects = [None, "xfade", "fade", "xfade", "fade"]
durations = [0.0, 0.8, 1.2, 0.5, 1.0]
result = plan_clip_durations(5, 45.0, transition_effects=effects, transition_durations=durations)
net = total_output_duration(result, effects, durations)
assert abs(net - 45.0) <= TOTAL_DURATION_TOLERANCE
@pytest.mark.parametrize("voice", [10.0, 20.0, 30.0, 60.0, 120.0])
def test_various_voice_durations(self, voice):
result = plan_clip_durations(5, voice)
net = total_output_duration(result)
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE
@pytest.mark.parametrize("tpl", RHYTHM_TEMPLATES)
def test_each_template_within_tolerance(self, tpl):
"""每种模板分配的总时长都应在误差范围内。"""
adapted = adapt_template_length(tpl, 5)
result = plan_clip_durations(5, 30.0, rhythm_template=adapted)
net = total_output_duration(result)
assert (
abs(net - 30.0) <= TOTAL_DURATION_TOLERANCE
), f"模板 {tpl} 总时长误差 {abs(net - 30.0):.3f}s > {TOTAL_DURATION_TOLERANCE}s"
# ── #1768 组合场景 ──────────────────────────────────────────────────────────
class TestCombinedScenarios:
def test_rhythm_plus_clamp_plus_tolerance(self):
"""节奏模板 + 素材钳制 + 误差校验 同时生效。"""
tpl = [3, 2, 1, 2, 1]
effects = [None, "xfade", None, "xfade", None]
tdurs = [0.0, 1.0, 0.0, 1.0, 0.0]
asset_durs = [15.0, 60.0, 60.0, 60.0, 60.0]
voice = 30.0
adapted = adapt_template_length(tpl, 5)
result = plan_clip_durations(
5,
voice,
transition_effects=effects,
transition_durations=tdurs,
rhythm_template=adapted,
asset_durations=asset_durs,
)
# 最小值保证
for dur in result:
assert dur >= MIN_CLIP_DURATION
# 最大值钳制(第一段 90% = 13.5)
assert result[0] <= 15.0 * 0.9 + 0.1
# 总时长误差
net = total_output_duration(result, effects, tdurs)
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE + 0.5
def test_many_clips_with_cycling_template(self):
"""片段数 > 模板长度时循环填充 + 钳制。"""
tpl = [2, 2, 1, 1, 2]
adapted = adapt_template_length(tpl, 8)
assert len(adapted) == 8
asset_durs = [20.0] * 8
result = plan_clip_durations(8, 40.0, rhythm_template=adapted, asset_durations=asset_durs)
assert len(result) == 8
for dur in result:
assert dur >= MIN_CLIP_DURATION
+317
View File
@@ -0,0 +1,317 @@
from datetime import datetime, timezone
"""AI数字人渲染 API 路由测试 — #1798.
至少 10 个测试覆盖路由层逻辑
"""
import os
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
def _make_mock_user(user_id="user-1"):
"""创建 mock 认证用户."""
user = MagicMock()
user.id = user_id
return user
def _make_mock_render_job(
job_id="render-1",
user_id="user-1",
status="pending",
progress=0,
output_video_url="",
output_cover_url="",
output_duration=0.0,
error_message="",
):
"""创建 mock 渲染任务."""
m = MagicMock()
m.id = job_id
m.user_id = user_id
m.project_id = ""
m.lipsync_job_id = "lipsync-1"
m.script_id = "script-1"
m.b_roll_segments = []
m.title_config = {}
m.cover_config = {}
m.status = status
m.progress = progress
m.output_video_url = output_video_url
m.output_cover_url = output_cover_url
m.output_duration = output_duration
m.error_message = error_message
m.submitted_at = None
m.started_at = None
m.completed_at = None
m.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
m.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
return m
class TestRenderRoutes:
"""路由层测试(通过 mock service 测试路由逻辑)."""
def _get_client(self):
"""获取测试客户端."""
from app.main import app
from fastapi.testclient import TestClient
return TestClient(app)
def test_create_render_job_success(self):
from app.api.routes.ai_avatar_render import router
from app.schemas.ai_avatar_render import AiAvatarRenderJobResponse
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job()
mock_service.create_render_job.return_value = mock_job
# 直接测试路由函数
from app.api.routes.ai_avatar_render import create_render_job
mock_user = _make_mock_user()
body = MagicMock()
body.lipsync_job_id = "lipsync-1"
body.script_id = "script-1"
body.b_roll_segments = []
body.title_config = {}
body.cover_config = {}
body.project_id = ""
result = create_render_job(
body=body,
current_user=mock_user,
svc=mock_service,
)
assert result.id == "render-1"
mock_service.create_render_job.assert_called_once()
def test_create_render_job_lipsync_not_found(self):
from app.api.routes.ai_avatar_render import create_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_service.create_render_job.side_effect = AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound")
mock_user = _make_mock_user()
body = MagicMock()
body.lipsync_job_id = "nonexistent"
body.script_id = "script-1"
body.b_roll_segments = []
body.title_config = {}
body.cover_config = {}
body.project_id = ""
with pytest.raises(HTTPException) as exc_info:
create_render_job(body=body, current_user=mock_user, svc=mock_service)
assert exc_info.value.status_code == 404
def test_create_render_job_lipsync_not_completed(self):
from app.api.routes.ai_avatar_render import create_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_service.create_render_job.side_effect = AiAvatarRenderError(
"对口型任务状态为 processing", code="LipsyncJobNotCompleted"
)
mock_user = _make_mock_user()
body = MagicMock()
body.lipsync_job_id = "lipsync-1"
body.script_id = "script-1"
body.b_roll_segments = []
body.title_config = {}
body.cover_config = {}
body.project_id = ""
with pytest.raises(HTTPException) as exc_info:
create_render_job(body=body, current_user=mock_user, svc=mock_service)
assert exc_info.value.status_code == 400
def test_get_render_job_success(self):
from app.api.routes.ai_avatar_render import get_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job()
mock_service.get_render_job.return_value = mock_job
result = get_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
assert result.id == "render-1"
def test_get_render_job_not_found(self):
from app.api.routes.ai_avatar_render import get_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_service.get_render_job.return_value = None
with pytest.raises(HTTPException) as exc_info:
get_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
assert exc_info.value.status_code == 404
def test_list_render_jobs(self):
from app.api.routes.ai_avatar_render import list_render_jobs
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_jobs = [_make_mock_render_job(f"render-{i}") for i in range(3)]
mock_service.list_render_jobs.return_value = (mock_jobs, 3)
result = list_render_jobs(
project_id="",
status="",
offset=0,
limit=20,
current_user=_make_mock_user(),
svc=mock_service,
)
assert result["total"] == 3
assert len(result["items"]) == 3
def test_cancel_render_job_success(self):
from app.api.routes.ai_avatar_render import cancel_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job(status="cancelled")
mock_service.cancel_render_job.return_value = mock_job
result = cancel_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
assert result.status == "cancelled"
def test_cancel_render_job_not_found(self):
from app.api.routes.ai_avatar_render import cancel_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_service.cancel_render_job.return_value = None
with pytest.raises(HTTPException) as exc_info:
cancel_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
assert exc_info.value.status_code == 404
def test_cancel_render_job_not_cancellable(self):
from app.api.routes.ai_avatar_render import cancel_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job(status="completed")
mock_service.cancel_render_job.return_value = mock_job
with pytest.raises(HTTPException) as exc_info:
cancel_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
assert exc_info.value.status_code == 400
def test_retry_render_job_success(self):
from app.api.routes.ai_avatar_render import retry_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job(status="pending")
mock_service.retry_render_job.return_value = mock_job
result = retry_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
assert result.status == "pending"
def test_retry_render_job_not_failed(self):
from app.api.routes.ai_avatar_render import retry_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_job = _make_mock_render_job(status="completed")
mock_service.retry_render_job.return_value = mock_job
with pytest.raises(HTTPException) as exc_info:
retry_render_job(job_id="render-1", current_user=_make_mock_user(), svc=mock_service)
assert exc_info.value.status_code == 400
def test_retry_render_job_not_found(self):
from app.api.routes.ai_avatar_render import retry_render_job
from app.services.ai_avatar_render_service import AiAvatarRenderService
from fastapi import HTTPException
mock_service = MagicMock(spec=AiAvatarRenderService)
mock_service.retry_render_job.return_value = None
with pytest.raises(HTTPException) as exc_info:
retry_render_job(job_id="nonexistent", current_user=_make_mock_user(), svc=mock_service)
assert exc_info.value.status_code == 404
class TestBrollOverlayFilter:
"""FFmpeg B-roll 滤镜构建测试."""
def test_empty_segments_returns_empty(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
result = build_broll_overlay_filter([], 30.0)
assert result == ""
def test_pip_mode_generates_overlay(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
segments = [
{
"script_segment_index": 0,
"asset_url": "https://example.com/broll.mp4",
"mode": "pip",
"start_time": 5.0,
"end_time": 10.0,
"pip_position": "bottom_right",
"pip_scale": 0.3,
}
]
result = build_broll_overlay_filter(segments, 30.0)
assert "overlay" in result or "scale=" in result
def test_fullscreen_mode_generates_concat(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
segments = [
{
"script_segment_index": 0,
"asset_url": "https://example.com/broll.mp4",
"mode": "fullscreen",
"start_time": 5.0,
"end_time": 10.0,
}
]
result = build_broll_overlay_filter(segments, 30.0)
assert "trim" in result or "concat" in result
def test_cover_extract_command(self):
from packages.domain.video_filter_builder import build_cover_extract_command
cmd = build_cover_extract_command({"timestamp": 5.0}, "/tmp/cover.jpg")
assert "ffmpeg" in cmd
assert "5.0" in cmd
assert "/tmp/cover.jpg" in cmd
def test_cover_extract_empty_config(self):
from packages.domain.video_filter_builder import build_cover_extract_command
cmd = build_cover_extract_command({}, "/tmp/cover.jpg")
assert "ffmpeg" in cmd
def test_cover_extract_with_size(self):
from packages.domain.video_filter_builder import build_cover_extract_command
cmd = build_cover_extract_command(
{"timestamp": 3.0, "width": 1280, "height": 720},
"/tmp/cover.jpg",
)
assert "scale=" in cmd
+517
View File
@@ -0,0 +1,517 @@
"""AI数字人渲染 Service 单元测试 — #1798.
至少 15 个测试覆盖 Service 层核心逻辑
"""
import os
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
def _make_mock_db():
"""创建 mock 数据库 session."""
mock_db = MagicMock()
mock_db.add = MagicMock()
mock_db.flush = MagicMock()
mock_db.commit = MagicMock()
mock_db.refresh = MagicMock()
return mock_db
def _make_mock_render_job(
job_id="render-1",
user_id="user-1",
status="pending",
progress=0,
output_video_url="",
output_cover_url="",
output_duration=0.0,
error_message="",
lipsync_job_id="lipsync-1",
script_id="script-1",
):
"""创建 mock 渲染任务."""
m = MagicMock()
m.id = job_id
m.user_id = user_id
m.project_id = ""
m.lipsync_job_id = lipsync_job_id
m.script_id = script_id
m.b_roll_segments = []
m.title_config = {}
m.cover_config = {}
m.status = status
m.progress = progress
m.output_video_url = output_video_url
m.output_cover_url = output_cover_url
m.output_duration = output_duration
m.error_message = error_message
m.submitted_at = None
m.started_at = None
m.completed_at = None
m.created_at = None
m.updated_at = None
return m
def _make_mock_lipsync_job(
job_id="lipsync-1",
user_id="user-1",
status="completed",
output_video_url="https://output.mp4",
output_duration=30.0,
):
"""创建 mock 对口型任务."""
m = MagicMock()
m.id = job_id
m.user_id = user_id
m.status = status
m.output_video_url = output_video_url
m.output_duration = output_duration
return m
def _make_mock_script(script_id="script-1", user_id="user-1"):
"""创建 mock 文案."""
m = MagicMock()
m.id = script_id
m.user_id = user_id
m.title = "测试文案"
return m
class TestSchemaValidation:
"""Schema 验证测试."""
def test_valid_broll_segment(self):
from app.schemas.ai_avatar_render import BRollSegment
seg = BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="fullscreen",
start_time=5.0,
end_time=10.0,
)
assert seg.mode == "fullscreen"
assert seg.start_time == 5.0
def test_invalid_mode(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="fullscreen 或 pip"):
BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="invalid",
start_time=5.0,
end_time=10.0,
)
def test_end_time_must_exceed_start_time(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="end_time 必须大于 start_time"):
BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="fullscreen",
start_time=10.0,
end_time=5.0,
)
def test_asset_url_must_be_http(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="HTTP"):
BRollSegment(
script_segment_index=0,
asset_url="ftp://example.com/broll.mp4",
mode="fullscreen",
start_time=5.0,
end_time=10.0,
)
def test_asset_url_empty(self):
from app.schemas.ai_avatar_render import BRollSegment
with pytest.raises(ValueError, match="不能为空"):
BRollSegment(
script_segment_index=0,
asset_url=" ",
mode="fullscreen",
start_time=5.0,
end_time=10.0,
)
def test_create_request_valid(self):
from app.schemas.ai_avatar_render import BRollSegment, CreateAiAvatarRenderRequest
req = CreateAiAvatarRenderRequest(
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[
BRollSegment(
script_segment_index=0,
asset_url="https://example.com/broll.mp4",
mode="pip",
start_time=5.0,
end_time=10.0,
)
],
)
assert req.lipsync_job_id == "lipsync-1"
assert len(req.b_roll_segments) == 1
def test_create_request_empty_lipsync_job_id(self):
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
with pytest.raises(ValueError, match="lipsync_job_id 不能为空"):
CreateAiAvatarRenderRequest(
lipsync_job_id=" ",
script_id="script-1",
)
def test_create_request_empty_script_id(self):
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
with pytest.raises(ValueError, match="script_id 不能为空"):
CreateAiAvatarRenderRequest(
lipsync_job_id="lipsync-1",
script_id=" ",
)
class TestAiAvatarRenderService:
"""Service 层单元测试(纯 mock,不依赖数据库)."""
def test_create_job_success(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
# 模拟 query 链式调用
mock_query = MagicMock()
# 第一次 query: LipsyncJobModel
mock_lipsync_filter = MagicMock()
mock_lipsync_filter.first.return_value = _make_mock_lipsync_job()
mock_lipsync_query = MagicMock()
mock_lipsync_query.filter.return_value = mock_lipsync_filter
# 第二次 query: ScriptModel
mock_script_filter = MagicMock()
mock_script_filter.first.return_value = _make_mock_script()
mock_script_query = MagicMock()
mock_script_query.filter.return_value = mock_script_filter
mock_db.query.side_effect = [mock_lipsync_query, mock_script_query]
svc = AiAvatarRenderService(mock_db)
job = svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
assert job.status == "pending"
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
def test_create_job_lipsync_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="对口型任务不存在"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="nonexistent",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_create_job_lipsync_not_completed(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_job = _make_mock_lipsync_job(status="processing")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_lipsync_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="仅 completed 状态可渲染"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_create_job_lipsync_no_output(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_video_url="")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_lipsync_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="输出视频 URL 为空"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="script-1",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_create_job_script_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = _make_mock_db()
mock_lipsync_query = MagicMock()
mock_lipsync_filter = MagicMock()
mock_lipsync_filter.first.return_value = _make_mock_lipsync_job()
mock_lipsync_query.filter.return_value = mock_lipsync_filter
mock_script_query = MagicMock()
mock_script_filter = MagicMock()
mock_script_filter.first.return_value = None
mock_script_query.filter.return_value = mock_script_filter
mock_db.query.side_effect = [mock_lipsync_query, mock_script_query]
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError, match="文案不存在或无权访问"):
svc.create_render_job(
user_id="user-1",
lipsync_job_id="lipsync-1",
script_id="nonexistent",
b_roll_segments=[],
title_config={},
cover_config={},
)
def test_get_render_job_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.get_render_job("render-1", "user-1")
assert result is mock_job
def test_get_render_job_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.get_render_job("nonexistent", "user-1")
assert result is None
def test_list_render_jobs(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_jobs = [_make_mock_render_job(f"render-{i}") for i in range(3)]
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 3
mock_query.order_by.return_value = mock_query
mock_query.offset.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = mock_jobs
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
items, total = svc.list_render_jobs(user_id="user-1")
assert total == 3
assert len(items) == 3
def test_list_render_jobs_with_project_filter(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_query = MagicMock()
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 1
mock_query.order_by.return_value = mock_query
mock_query.offset.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = [_make_mock_render_job()]
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
items, total = svc.list_render_jobs(user_id="user-1", project_id="proj-1")
assert total == 1
# filter should be called for user_id and project_id
assert mock_query.filter.call_count >= 2
def test_cancel_render_job_success(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="pending")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.cancel_render_job("render-1", "user-1")
assert result is mock_job
assert mock_job.status == "cancelled"
def test_cancel_render_job_not_pending(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="completed")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.cancel_render_job("render-1", "user-1")
# 非 pending 状态不可取消,状态不变
assert result.status == "completed"
def test_cancel_render_job_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.cancel_render_job("nonexistent", "user-1")
assert result is None
def test_retry_render_job_success(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="failed", error_message="渲染失败")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.retry_render_job("render-1", "user-1")
assert result.status == "pending"
assert result.progress == 0
assert result.error_message == ""
def test_retry_render_job_not_failed(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="completed")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.retry_render_job("render-1", "user-1")
assert result is None
def test_retry_render_job_not_found(self):
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
result = svc.retry_render_job("nonexistent", "user-1")
assert result is None
def test_execute_render_job_not_found(self):
"""execute_render 在任务不存在时应静默返回."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
# 不应抛异常
svc.execute_render("nonexistent")
def test_execute_render_cancelled_job(self):
"""execute_render 在任务已取消时应静默返回."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_job = _make_mock_render_job(status="cancelled")
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
svc.execute_render("render-1")
# 不应执行渲染逻辑
mock_db.commit.assert_not_called()
def test_error_exception_has_code(self):
from app.services.ai_avatar_render_service import AiAvatarRenderError
err = AiAvatarRenderError("测试错误", code="TestCode")
assert err.code == "TestCode"
assert str(err) == "测试错误"

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