Compare commits

..

63 Commits

Author SHA1 Message Date
xiaoxia f412b322f0 fix(ci): actions/cache@v4 加 continue-on-error——缓存故障不阻断 job
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m20s
AI Code Review / AI Code Review (pull_request) Successful in 3m32s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m5s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m18s
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 48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m28s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 14m53s
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 / Validate - Python (mypy + alembic) (pull_request) Successful in 16m10s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 26m53s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 37m22s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 47m49s
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 / Canary Release to 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 2s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m38s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m30s
所有 5 个 actions/cache@v4 步骤加 continue-on-error: true:
- validate-style pip cache
- validate-security pip cache
- validate-python pip cache
- unit-tests pip cache
- frontend-unit-test npm cache

当 Gitea Actions 缓存基础设施出现瞬态故障(git cache EOF /
归档损坏)时,cache restore 步骤会标黄但不中断 job,
后续的 pip install 重试逻辑自动 fresh download 兜底。
2026-08-31 22:42:01 +08:00
xiaoxia bc9df316dc fix(ci): Web 镜像 buildx 幽灵缓存防护——Git Tree Hash Cache Bust
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) 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 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 16s
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 / Frontend Unit Tests (push) Successful in 3m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 6m4s
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 / 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 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 39s
CI/CD Pipeline / PR Build Web Image (pull_request) 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 / 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 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) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
问题:ci-builder-persist 持久化 builder 的 buildkit 层缓存偶尔出现'幽灵命中':
COPY apps/web/ ./ 步骤认为自己没变(实际文件已改),导致 vite build 不执行,
打出来的 Web 镜像前端文件是旧的。

修复原理:
把 apps/web/ 目录的 git tree hash 作为 build arg 传入 Dockerfile。
buildx 把 build arg 值作为缓存键的一部分,hash 变了 → RUN 步骤缓存失效 →
vite build 必须重新执行。依赖层(npm ci)不受影响,仍然正常缓存。

改动:
1. web.Dockerfile:新增 ARG SOURCE_HASH,在构建步骤写入 .cache_bust 文件
2. docker_build_push.sh:Web 镜像构建时计算 git tree hash 并传入
3. docker_build_only.sh:同上

验证:
- 合并后触发 push 到 develop
- 查看 Build Staging Web Image 日志,确认输出 Web cache bust: SOURCE_HASH=xxxxx
- 修改 apps/web/ 下文件再次 push,确认 SOURCE_HASH 值变化且 vite build 执行
2026-08-31 22:40:57 +08:00
xiaoxia 5ed2ee1192 feat: 统一模板 segments 数据源到 template_clip_configs (#1579)
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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (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 3s
CI/CD Pipeline / Frontend Lint (push) 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 4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
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 4m30s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m32s
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 Worker Image (pull_request) Successful in 1m39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m3s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 9m57s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
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
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) 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 / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-31 22:26:03 +08:00
xiaoxia 061554af89 chore: trigger staging deploy for template segments unification
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (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 2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 2m47s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m0s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 9m27s
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 48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 44s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 50s
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 (push) Successful in 4m16s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 54s
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 (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 / 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 / CI Gate (pull_request) Successful in 1s
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 / Validate - Security (push) Successful in 11m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m27s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m40s
CI/CD Pipeline / Integration Tests (push) Successful in 36m0s
CI/CD Pipeline / Unit 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-08-31 21:43:42 +08:00
CI Bot 87cca302f4 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
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 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 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Failing after 1s
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 (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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m54s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m25s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m40s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m56s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m23s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m44s
AI Code Review / AI Code Review (pull_request) Successful in 11m40s
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 / CI Gate (pull_request) Successful in 1s
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) 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 / 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 / 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 / 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
2026-08-31 13:26:58 +00:00
xiaoxia deb127ae08 feat: 统一模板 segments 数据源为 template_clip_configs (#1579)
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 / 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 / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m29s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m14s
AI Code Review / AI Code Review (pull_request) Failing after 5m22s
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 (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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m3s
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 1m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m35s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 9m58s
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 / Integration Tests (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 / 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
2026-08-31 21:07:43 +08:00
xiaoxia a9438ed996 chore: re-trigger CI (previous run had git-cache EOF infra failure)
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 / Check if frontend-only change (pull_request) Successful in 1s
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 / Build 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 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 4m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m43s
AI Code Review / AI Code Review (pull_request) Successful in 4m52s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 19m8s
CI/CD Pipeline / Validate - Style (pull_request) 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 / 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 / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-31 20:46:10 +08:00
xiaoxia 72f47592a9 fix(ci): 安全/风格检查 pip install 加固——防缓存损坏导致 CI 失败
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) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Check push changed paths (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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (pull_request) 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 / Check push changed paths (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
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
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 5m45s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 6m8s
问题:Gitea Actions cache 返回损坏归档时(cache EOF/deserialization failed),
pip install pip-audit/detect-secrets/vulture 因 hash mismatch 直接 exit 1,
导致 validate-security/validate-style 整体失败。

修复:
- detect-secrets/pip-audit/vulture 安装加 --no-cache-dir
- pip-audit 安装失败后 graceful skip(本身是 advisory-only)
- detect-secrets 安装失败后重试一次 --no-binary
- vulture 安装失败后 skip 死代码检测(本身是 advisory-only)
2026-08-31 20:16:34 +08:00
xiaoxia b6c340a352 fix(ci): check-push-paths 恢复脚本下载——e052c054移除checkout导致脚本找不到 (#1582)
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 / Check push changed paths (push) Successful in 7s
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 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m58s
AI Code Review / AI Code Review (pull_request) Failing after 5m36s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m21s
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 Staging Worker Image (push) Successful in 1m59s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m1s
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 / 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 11m9s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m24s
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 cancelled
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m49s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m3s
CI/CD Pipeline / Validate - Security (push) Successful in 26m20s
CI/CD Pipeline / Integration Tests (push) Successful in 27m1s
CI/CD Pipeline / Unit Tests (push) Successful in 34m33s
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-08-31 20:00:26 +08:00
xiaoxia 6e76b45d34 feat: 统一模板 segments 数据源为 template_clip_configs
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 / 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 / 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 / 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
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
- list_segments/list_by_user 优先读 clip_configs,回退 template_segments
- create_segments 写入 clip_configs,material_type 存 config JSON
- copy_template 复用 create_segments,使用 created.id
- delete 复用 delete_segments_by_template 清理两张表
- migration 060: 方言感知(postgresql/mysql)一次性迁移历史数据
- 11 个新测试 + 120 个现有测试全部通过

Closes #1579
2026-08-31 19:58:51 +08:00
xiaoxia cd8e77c1f6 fix(ci): deploy-staging 全栈 push 被错误 skip——success() 改 !cancelled()
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 / 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 push changed paths (push) Failing after 2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m19s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m52s
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 (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 / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / 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 Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m33s
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 / 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 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 1m37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m43s
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 / CI Gate (pull_request) Successful in 4s
AI Code Review / AI Code Review (pull_request) Failing after 6m33s
CI/CD Pipeline / Validate - Style (push) Successful in 6m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m52s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m53s
CI/CD Pipeline / Validate - Security (push) Successful in 19m48s
CI/CD Pipeline / Integration Tests (push) Successful in 19m2s
CI/CD Pipeline / Unit Tests (push) Successful in 27m48s
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
问题:retag-staging-skipped 在全栈 push 时所有矩阵项都被 skipped(因为都有对应 build),
success() 认为 skipped 不满足条件,导致 deploy-staging 被跳过。

修复:success() → !cancelled(),允许 skipped 依赖不阻塞 deploy。
删除两行误导性注释(声称 success() 不阻塞 skipped,实际恰恰相反)。

安全兜底:build-staging 真正失败时 workflow 会 cancel,!cancelled() 仍会阻止 deploy。
2026-08-31 18:58:18 +08:00
xiaoxia 4ae6394c95 ci: batch2 speedup - merge validate-type-check+migration into validate-python, split validate-code-quality into validate-style+validate-security
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) Failing after 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 / Check if frontend-only change (pull_request) Successful in 2s
PR Automation / Auto Merge on CI Green + Approved (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 Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging 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 - 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 / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 3m52s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m19s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m41s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m18s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m12s
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 - Security (push) Failing after 5m19s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) 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 / 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 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 3m32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m42s
CI/CD Pipeline / Integration Tests (push) Successful in 6m59s
AI Code Review / AI Code Review (pull_request) Successful in 9m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (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
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 / Unit Tests (push) Successful in 20m36s
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
Change 5: Merge validate-type-check + validate-migration into validate-python
- Single checkout + single pip install → mypy + alembic
- Saves ~2min checkout + ~1min pip install + 1 runner slot
- Preserves DATABASE_URL env for alembic migration validation

Change 6: Split validate-code-quality into validate-style + validate-security
- validate-style: compileall + black/isort/ruff + vulture (advisory) + auto-fix (~3min)
- validate-security: detect-secrets + bandit (advisory) + pip-audit (advisory) + CI scripts syntax (~5min)
- Both jobs run in parallel, reducing wall-clock time from ~10min to ~5min

Updated references:
- ci-gate needs: validate-code-quality → validate-style + validate-security
- ci-gate needs: validate-type-check + validate-migration → validate-python
- build-production needs: validate-code-quality → validate-style + validate-security
- Removed obsolete scripts/ci/validate_code_quality.sh
2026-08-31 18:25:00 +08:00
xiaoxia e052c0545e perf(ci): 流水线第一批提速 — 移除冗余checkout/ffmpeg,解除假依赖 (#1577)
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 / PR Build Worker Image (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 / Check push changed paths (push) Failing after 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
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
PR Automation / Auto Merge on CI Green + Approved (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 / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (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 / 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 / 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 4m23s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 6m10s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m26s
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 API Image (pull_request) Successful in 6m13s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 7m18s
AI Code Review / AI Code Review (pull_request) Failing after 7m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m8s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 8m43s
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
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 - Code Quality (push) Successful in 9m39s
CI/CD Pipeline / Unit Tests (push) Successful in 20m1s
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 / CI Gate (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 / 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-08-31 18:16:04 +08:00
xiaoxia ee6fa3e1cf fix(ci): push 路径检测安全回溯——防止 concurrency 取消导致前端改动丢失 (#1578)
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m43s
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 3m41s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m22s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Validate - Migration (alembic) (push) Successful in 4m17s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m21s
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 / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m18s
CI/CD Pipeline / Integration 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 / 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 / Deploy Production (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m49s
AI Code Review / AI Code Review (pull_request) Failing after 8m58s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m47s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m18s
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 / PR Build Worker Image (pull_request) Successful in 3m29s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m12s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m20s
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 / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3m17s
CI/CD Pipeline / Frontend Lint (push) Failing after 14m10s
CI/CD Pipeline / Integration Tests (push) Successful in 6m28s
CI/CD Pipeline / Unit Tests (push) Successful in 16m12s
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-08-31 17:41:43 +08:00
xiaoxia afe78fb25f feat: 成片 duplicate_rate 百分比 + 素材高频使用自动排除 (#1573)
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 1s
PR Automation / Auto Merge on CI Green + Approved (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 / 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 (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 - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 if frontend-only change (pull_request) Successful in 4m51s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m35s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m15s
AI Code Review / AI Code Review (pull_request) Failing after 6m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7m8s
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 Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m31s
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 Web Image (push) 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 1m54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m46s
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
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m46s
CI/CD Pipeline / CI Gate (pull_request) Successful in 30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m36s
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) Successful in 34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m14s
CI/CD Pipeline / Integration Tests (push) Successful in 5m41s
CI/CD Pipeline / Unit Tests (push) Successful in 19m12s
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-08-31 16:56:52 +08:00
xiaoxia 42939a557f feat(products): 成片库视频内联播放,去掉弹窗 + 自适应比例 (#1576)
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 0s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (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 / 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
PR Automation / Auto Merge on CI Green + Approved (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 / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Check push changed paths (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 / 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 if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (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 / 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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
2026-08-31 16:55:14 +08:00
xiaoxia 864f0e8eb2 fix(assets): 诊断接口改用 /projects/{projectId}/asset-diagnosis 路径 (#1575)
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 / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 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 / Build Staging API Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Validate - Migration (alembic) (push) Successful in 4m24s
CI/CD Pipeline / Check push changed paths (push) Successful in 5m2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5m17s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m23s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m28s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m0s
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m11s
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
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 1m31s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 1m38s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
AI Code Review / AI Code Review (pull_request) Failing after 13m17s
CI/CD Pipeline / Integration Tests (push) Successful in 6m4s
CI/CD Pipeline / Unit Tests (push) Successful in 17m59s
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 / ACR Image Cleanup (push) Successful in 4m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 6m29s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m44s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-31 16:06:01 +08:00
xiaoxia 243b0e7c78 ci: CI 瞬态故障自动重试机制
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) 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 (pull_request) Successful in 4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m48s
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 - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4m41s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m35s
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 / Validate - Type Check (mypy) (push) Successful in 5m51s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m27s
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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 8m20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m23s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 9m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m50s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m14s
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 / Build Staging Worker Image (push) Successful in 3m25s
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 / Retag skipped Staging API 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 3m7s
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 / Retag skipped Staging Web Image (push) Successful in 2m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m22s
CI/CD Pipeline / Unit Tests (push) Successful in 17m19s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 6m12s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m56s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m39s
问题:
  当 runner 出现瞬态故障(磁盘满/docker挂/网络抖动),分配到该 runner
  的 job 会在 checkout/setup 阶段失败。这种失败与代码无关,但没有自动
  重试机制,需要人工干预。

方案:
  ci_transient_retry.sh - 每 5 分钟扫描最近 30 分钟的 runs:
  - 检测特征:失败 job 的 checkout/setup 阶段失败 + 同 run 其他 job 成功
  - 自动 re-run 整个 workflow(Gitea API /rerun)
  - 每个 run 最多重试 1 次(状态文件跟踪,24h 自动清理)
  - 支持 --dry-run 模式
  - 日志记录到 /var/log/ci-auto-retry.log

判定逻辑:
  - 失败 job 第一个 step 是 checkout/setup 且 conclusion=failure → 瞬态
  - 同一 run 至少 2 个 job 成功(排除代码全挂)
  - 纯代码失败(lint/test/compile)→ 不重试

部署:
  - systemd timer: ci_transient_retry.{service,timer}
  - 或 crontab: */5 * * * *
  - Token 通过环境变量 / EnvironmentFile 注入,不硬编码
2026-08-31 15:23:40 +08:00
xiaoxia dddbd3bfa2 fix(editing): 模板片段时长从范围改为固定值 (#1571)
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 / 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 / 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 / Frontend Lint (push) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3m17s
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 - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m29s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) 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 / 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m57s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 3m25s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m25s
AI Code Review / AI Code Review (pull_request) Failing after 8m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m43s
CI/CD Pipeline / Retag skipped Staging Web 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
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 9m4s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m57s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 2m31s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2m23s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-31 15:11:02 +08:00
xiaoxia ab23ca7e5a fix(assets): 智能选素材传 limit 参数,避免「有几个选几个」 (#1570)
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Check if frontend-only change (pull_request) Successful in 3m8s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m11s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m27s
CI/CD Pipeline / Check push changed paths (push) Successful in 5m48s
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 Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 7m22s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m11s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m28s
AI Code Review / AI Code Review (pull_request) Failing after 8m31s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m47s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2m27s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 1m26s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 1m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m15s
CI/CD Pipeline / Integration Tests (push) Successful in 5m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m51s
CI/CD Pipeline / Unit Tests (push) Successful in 18m2s
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-08-31 14:21:00 +08:00
xiaoxia dc816991d6 fix(assets): smart-match 响应扁平化——item 顶层直出素材字段,修复自动选素材全链路 404/422
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
PR Automation / Auto Merge on CI Green + Approved (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 / 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 (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 - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Check if frontend-only change (pull_request) Successful in 3m38s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m3s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4m24s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m58s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m34s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m40s
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 / Frontend Unit Tests (push) Successful in 7m17s
CI/CD Pipeline / CI Gate (pull_request) Successful in 47s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 45s
AI Code Review / AI Code Review (pull_request) Successful in 7m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Frontend Lint (push) Failing after 10m18s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m57s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m45s
CI/CD Pipeline / Integration Tests (push) Successful in 3m5s
CI/CD Pipeline / Unit Tests (push) Successful in 14m44s
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
P0 线上事故:前端 smartMatchAssets 按 {items: AssetItem[]} 解析,
items.map(a => a.id) 直接取 id;但后端 SmartMatchItem 为
{asset: AssetResponse, score, breakdown} 包装结构,导致 id 全为
undefined → GET /assets/undefined 404 连发、POST /clips/from-assets
携带 null 422,自动模式预览无法生成、标题不显示。

修复:
- SmartMatchItem 改为继承 AssetResponse 的扁平结构:id/usable/
  used_duration/available_duration/used_ratio 等素材字段全部在 item
  顶层,前端拿到即可用;score/breakdown 评分字段保留
- 路由用 model_dump() 解包构造,packages.domain.smart_select_assets
  域层 SmartMatchResult.asset 不动,generation_tasks 内部生成链路
  ([r.asset.id for r in results])零影响
- ClipsFromAssetsRequest 加 pre validator 过滤 null/空串/空白 id;
  from-assets 路由层二次归一化兜底,全非法时 400 而非 422/500

测试:扁平结构 3 用例(顶层 id/无 asset 包装/余量字段可读/评分保留)、
端点 TestClient 断言更新、from-assets 容错 4 用例;相关 95 用例全过
2026-08-31 13:35:05 +08:00
xiaoxia f7693baeae ci(retag/deploy): 增加镜像内容校验,防止静默部署旧/损坏镜像
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 / Check push changed paths (pull_request) 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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m58s
CI/CD Pipeline / Check push changed paths (push) Successful in 4m19s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m22s
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 Web Image (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m43s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m5s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m15s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m51s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m31s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m23s
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 / CI Gate (pull_request) Successful in 2m28s
AI Code Review / AI Code Review (pull_request) Successful in 11m10s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 2m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Integration Tests (push) Successful in 4m31s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m32s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m23s
CI/CD Pipeline / Unit Tests (push) Successful in 17m40s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / 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
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m1s
retag_skipped_image.sh 加固:
- 源镜像 layers 有效性检查(layers=0 拒绝 retag)
- 镜像创建时间审计(>72h 输出警告)
- digest 审计记录,方便溯源

ci_staging_deploy.sh 加固:
- pull 后逐服务校验镜像 layers 有效性
- 记录每个镜像的 digest/size/created
- 对比上次部署 manifest,标注变化状态
- 写入 deploy-manifest.json 供下次对比
- 校验失败直接阻断部署

concurrency 保持 cancel-in-progress: true(不变)
生产发版链路不动
2026-08-31 12:01:19 +08:00
xiaoxia 107a391c47 fix(ci): 修正自检step插入位置(独立step而非Checkout内部)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 4m31s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m42s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m49s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 7m20s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m55s
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 Worker Image (push) Successful in 2m41s
CI/CD Pipeline / PR Build Web 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m18s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m3s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m37s
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 (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m18s
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 1m30s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 2m7s
AI Code Review / AI Code Review (pull_request) Successful in 13m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / Integration Tests (push) Successful in 4m44s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m36s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Unit Tests (push) Successful in 19m3s
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 / CI Gate (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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m9s
上一版 selfcheck 被插进 Checkout code step 内部导致 shell/env 键重复,
Gitea 拒绝解析整个 workflow(DetectWorkflows: ignore invalid workflow)。
本版将 selfcheck 作为独立 step 放在 Checkout 之后。
2026-08-31 11:57:59 +08:00
xiaoxia 2376f0c807 fix(ci): 自检step移到Checkout之后——修复PR分支无脚本导致的误失败 2026-08-31 11:57:59 +08:00
xiaoxia 8c0c34300f fix(ci): 僵尸run复活防护——重负载job自检+dedupe竞态增强
P1修复(2026-08-30 CI巡检实证):
Gitea 1.26.4 调度器bug:被 concurrency cancel 的 run,其 job 会在数小时后
被复活重跑(attempt=2)。实测 42597 号 run 18:46 被取消,21:46 复活,
与正常流水线并行烧了1.5小时 runner(Validate+UnitTests+staging构建全套),
合并高峰双流水线CPU超卖导致单测 14分→30分。

改动:
1. 新增 scripts/ci/ci_run_selfcheck.sh:job 启动第一步实时查本 run 状态,
   若 conclusion 为 cancelled/skipped(被复活的僵尸job)立即退出(exit 78),
   API 故障时不阻断(unknown 照常执行)
2. ci-pipeline.yml 12 个重量级 job(validate×3、unit/integration/frontend×2、
   build-pr/build-staging/staging-e2e/api-tests/build-production)首步插入自检
3. dedupe-check 情形2增强:同一 head_sha 的 push 流水线不只是在跑时去重,
   已 success 完成时 PR 侧测试同样跳过(消除竞态窗口双跑)
2026-08-31 11:57:59 +08:00
xiaoxia 3bc850dc0f fix(ci): 定时任务防僵尸自保——urlopen补超时+扫描墙钟上限+降频
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 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 / PR Build Worker Image (push) Has been skipped
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 - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Check push changed paths (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 / Check if frontend-only change (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 / 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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m54s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
P0修复:pr_auto_scan.py 的 api_request() 中 urlopen 无 timeout,
网络异常时永久阻塞(2026-08-30 实测卡死195分钟占满runner slot),
而 Gitea 1.26.4 的 timeout-minutes 对卡死job不生效。

改动:
1. pr_auto_scan.py: urlopen 加 timeout=15s + URLError/socket.timeout
   异常捕获(返回599);新增 --max-wall-seconds(默认240s)墙钟上限,
   到点主动退出;merge_pr 的 sleep(30) 改为可中断轮询
2. acr_cleanup.py: 7处 urlopen 补 timeout=30s(生产清理链路同款隐患)
3. auto_fix_formatting.py: 5处 urlopen 补 timeout=15s
4. pr-auto-scan.yml / ci-trigger-monitor.yml: schedule 从 */5 降到 */10,
   24h运行次数各减半(288→144),监控告警阈值同步为10分钟
2026-08-31 11:28:10 +08:00
xiaoxia 301b591037 fix(test): 清理 sys.modules 时保留 video_processing.* 模块
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 / Frontend Lint (push) Has been skipped
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 / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Check push changed paths (push) Successful in 4m6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m17s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m2s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 6m16s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m59s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m37s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 10m24s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m59s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m23s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m49s
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 (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
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
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 1m56s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / Unit Tests (push) Successful in 18m44s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m33s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m39s
CI/CD Pipeline / Integration Tests (push) Successful in 7m16s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m7s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
清理逻辑跳过 video_processing.* 前缀的模块,确保 patch() 字符串
目标解析到的模块对象与 ingest_mod 内部引用的一致,避免 mock 失效。
修复 AI Code Review 阻塞级问题。
2026-08-31 11:11:43 +08:00
xiaoxia 8d8b565499 fix(test): upload_to_oss patcher 改用字符串目标,修复 ingest.py 局部 import 不生效
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 / 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 / Check if frontend-only change (pull_request) Successful in 2m59s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m29s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m45s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m10s
AI Code Review / AI Code Review (pull_request) Failing after 6m46s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m52s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m9s
CI/CD Pipeline / CI Gate (pull_request) Successful in 26s
patch.object(_oss_helpers_real, ...) 对 ingest.py 函数体内的
from video_processing.oss_helpers import upload_to_oss 不生效,
改为 patch("video_processing.oss_helpers.upload_to_oss", ...)
与 thumb patcher 策略一致,确保 patch.start() 时从 sys.modules
解析模块对象再打补丁。
2026-08-31 10:57:54 +08:00
xiaoxia 2662cd78fe fix(test): thumb patcher 改回字符串目标
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 0s
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 Staging API 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 / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m35s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m1s
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 / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m48s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m5s
AI Code Review / AI Code Review (pull_request) Successful in 4m26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m40s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m45s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m49s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m1s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m25s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m8s
CI/CD Pipeline / CI Gate (pull_request) Failing after 47s
patch.object(_thumb_gen_real) 对 ingest.py 的局部 import 不生效
(模块对象可能不同),改回 patch(字符串目标) 从 sys.modules 取真实模块打补丁。
核心修复(不注入 video_processing mock 到 sys.modules)不变。
2026-08-31 09:23:19 +08:00
CI Bot 26856e1015 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 / Build Staging API Image (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 / 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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m11s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m6s
AI Code Review / AI Code Review (pull_request) Successful in 4m27s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m36s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m43s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m13s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m3s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m25s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m12s
CI/CD Pipeline / CI Gate (pull_request) Failing after 44s
2026-08-31 01:11:43 +00:00
xiaoxia e5d7fddfcf fix(test): hevc 转码测试不再注入 video_processing mock 到 sys.modules
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 / 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 / Check if frontend-only change (pull_request) Successful in 3m43s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m1s
AI Code Review / AI Code Review (pull_request) Successful in 4m38s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m48s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m57s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m8s
CI/CD Pipeline / Validate - Code Quality (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 / 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
根因:sys.modules 注入 video_processing.oss_helpers / thumbnail_generator 的
MagicMock 即使清理,仍因 video_processing/__init__.py 的
`from . import oss_helpers` 导致包属性残留为 MagicMock,
跨文件污染 test_oss_helpers_pure / test_oss_upload_crash_fix /
test_p02_worker_oss_fix 等(39 个用例失败)。

修复:不再向 sys.modules 注入 video_processing.* mock,改为直接
import 真实模块后用 patch.object 打补丁(patch.stop 自动还原)。
worker_app.db / celery_app mock 保留(独立包树,不影响共享工具包)。
2026-08-31 09:04:05 +08:00
xiaoxia d2098b03ab fix(test): hevc 转码测试恢复 sys.modules 时删除全部 mock 条目(补 #1566 漏修) (#1567)
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 / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 / 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 (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 / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Check push changed paths (push) Successful in 3m58s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m52s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 6m12s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m23s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m48s
AI Code Review / AI Code Review (pull_request) Failing after 8m1s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m28s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m36s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m50s
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 API Image (pull_request) Successful in 2m42s
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 / PR Build Worker Image (pull_request) Successful in 3m2s
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
CI/CD Pipeline / Unit Tests (push) Failing after 19m8s
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 / Retag skipped Staging Web Image (push) Successful in 4m8s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2m8s
CI/CD Pipeline / Integration Tests (push) Successful in 6m8s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 19m45s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 20m27s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-31 02:51:01 +08:00
xiaoxia 40eca5e83f fix(web): 上传按钮模式+卡片文字重叠+AI配音时长+smart-match结构兼容 (#1564)
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 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) 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 / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Check if frontend-only change (pull_request) Successful in 3m44s
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 Web 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 / Check push changed paths (push) Successful in 3m57s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m12s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m55s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m13s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m2s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 1m12s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 1m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m6s
AI Code Review / AI Code Review (pull_request) Successful in 9m24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 9m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m29s
CI/CD Pipeline / Integration Tests (push) Successful in 3m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m3s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m35s
CI/CD Pipeline / Unit Tests (push) Failing after 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-31 02:06:03 +08:00
xiaoxia 4cf6f222ed fix(test): test_ingest_hevc_transcode_task 模块级 mock 用后清理,修复跨文件 mock 污染 (#1566)
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 / 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 / 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Check if frontend-only change (pull_request) Successful in 4m7s
CI/CD Pipeline / Check push changed paths (push) Successful in 5m7s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m19s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m20s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m49s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m27s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m48s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m12s
AI Code Review / AI Code Review (pull_request) Successful in 12m7s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m12s
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 2m26s
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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 16m57s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3m26s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 2m54s
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 / Integration Tests (push) Successful in 6m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
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
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m44s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m2s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m52s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-31 01:36:01 +08:00
xiaoxia 0fde7d687c fix(test): test_dedup_engine 不再 mock packages.shared 父包,修复 50 个 thumbnail 单测被 mock 污染 (#1563)
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 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
PR Automation / Auto Merge on CI Green + Approved (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 / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m57s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m51s
CI/CD Pipeline / Check push changed paths (push) Successful in 4m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m19s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m38s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m41s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 7m31s
AI Code Review / AI Code Review (pull_request) Successful in 7m55s
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 / 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 / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m35s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 16m43s
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 / 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 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 / Retag skipped Staging Web Image (push) Successful in 1m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m23s
CI/CD Pipeline / Integration Tests (push) Successful in 7m32s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 13m40s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to 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 45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m46s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m21s
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-08-31 00:36:06 +08:00
xiaoxia fb197e8386 fix(assets): 骨架屏网格列数同步真实网格(6列)+响应式断点对齐 (#1560)
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 / 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 / Check push changed paths (pull_request) Has been skipped
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 7s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Check push changed paths (push) Successful in 4m34s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m40s
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 / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 49s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web 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 / 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
AI Code Review / AI Code Review (pull_request) Successful in 5m56s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 59s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m32s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 1m18s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 1m17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m20s
CI/CD Pipeline / CI Gate (pull_request) Successful in 46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m17s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 22:21:01 +08:00
xiaoxia 15bc55f0ff fix(ingest): 修复竖屏 HEVC 转码方向错误(双重旋转/ffprobe 取错行/逗号转义) (#1559)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m45s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m58s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 40s
AI Code Review / AI Code Review (pull_request) Failing after 10m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m25s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 42s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
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 2m9s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m1s
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) Successful in 29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m10s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m35s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 13m26s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 15m35s
CI/CD Pipeline / Unit Tests (push) Failing after 22m35s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 22m36s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 22m59s
CI/CD Pipeline / CI Gate (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 21:56:02 +08:00
xiaoxia 61295b9c25 feat(tts): 正式合成支持克隆音色 + 保存到配音库改写 assets 素材体系 + 克隆接口支持 asset_id (#1556)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
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 - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Check push changed paths (push) Successful in 4m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m43s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m19s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m33s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m19s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 6m39s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 41s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7m2s
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 Web 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 / 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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m27s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m1s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m0s
CI/CD Pipeline / Unit Tests (push) Successful in 13m47s
CI/CD Pipeline / Build Production API 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 / Build Production Worker Image (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
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m58s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 18:46:06 +08:00
xiaoxia 63e68756ac feat(assets): 上传完成即时刷新素材网格+卡片尺寸缩小 (#1558)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m47s
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 / Check if frontend-only change (pull_request) Successful in 4m1s
CI/CD Pipeline / Retag skipped 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 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 / Deploy Staging (Watchtower auto-deploy) (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) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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 / 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
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 / 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 2m16s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 36s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m25s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 8m37s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (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 / 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 18:41:00 +08:00
xiaoxia 5c45629495 feat(assets): 上传即入库——批量上传队列化(并发3)+真实进度+刷新不丢 (#1557)
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 / Frontend Lint (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 / 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 4m37s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m37s
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 / Validate - Type Check (mypy) (push) Successful in 4m50s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m5s
AI Code Review / AI Code Review (pull_request) Successful in 7m56s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m40s
CI/CD Pipeline / Build Staging API Image (push) 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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m15s
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m37s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m55s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m20s
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 E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
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
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m1s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 2m18s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m49s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 2m16s
CI/CD Pipeline / Integration Tests (push) Successful in 4m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / Unit Tests (push) Successful in 15m57s
CI/CD Pipeline / Build Production API 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 / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m10s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m44s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 17:56:02 +08:00
xiaoxia dd71b644c0 feat(generate): 自动匹配模式删除「AI 已选素材」展示 (#1555)
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 / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) 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
PR Automation / Auto Merge on CI Green + Approved (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 / 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 / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3m18s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m6s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m5s
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 - Migration (alembic) (push) Successful in 4m30s
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 / Build Staging API Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m50s
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m28s
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 / Retag skipped Staging Web Image (push) 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 1m10s
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
AI Code Review / AI Code Review (pull_request) Successful in 6m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m20s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 1m8s
CI/CD Pipeline / CI Gate (pull_request) Successful in 57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m21s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m15s
CI/CD Pipeline / Integration Tests (push) Successful in 2m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m42s
CI/CD Pipeline / Unit Tests (push) Successful in 14m5s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / 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-08-30 15:56:00 +08:00
xiaoxia de97f5f0fb fix(voices): 配音库四连修复——进度条隔离/TTS克隆音色/克隆弹窗素材选择 (#1554)
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 (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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3m47s
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 / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) 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 / ACR Image Cleanup (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 / Validate - Type Check (mypy) (push) Successful in 5m12s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5m32s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m32s
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 Web Image (push) Successful in 2m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m23s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 59s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 1m20s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 1m25s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m29s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m33s
CI/CD Pipeline / CI Gate (pull_request) Successful in 43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m22s
CI/CD Pipeline / Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m11s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m50s
CI/CD Pipeline / Unit Tests (push) Successful in 13m33s
CI/CD Pipeline / Build Production API 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 / Build Production Worker Image (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-08-30 14:56:00 +08:00
xiaoxia 4f7746926c Merge pull request 'fix(ingest-jobs): GET /{job_id} 查不到时返回 404 而非 500' (#1553) from fix/ingest-jobs-404-not-found 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 / 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 (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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Check push changed paths (push) Successful in 4m7s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m34s
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 - Migration (alembic) (push) Successful in 5m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m15s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m42s
CI/CD Pipeline / Retag skipped Staging API 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 7m16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m35s
AI Code Review / AI Code Review (pull_request) Failing after 7m34s
CI/CD Pipeline / CI Gate (pull_request) Successful in 54s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 7m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / Integration Tests (push) Successful in 2m23s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m15s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m45s
CI/CD Pipeline / Unit Tests (push) Successful in 13m34s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m44s
2026-08-30 12:00:07 +08:00
xiaoxia 1106ba5c45 fix(voices): 配音素材上传卡「处理中…」修复 (#1552)
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 / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 / 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 (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 - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4m0s
CI/CD Pipeline / Validate - Migration (alembic) (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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m26s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m16s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m16s
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 Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m26s
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 Web Image (push) Successful in 2m4s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) 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 / PR Build API Image (pull_request) Successful in 1m37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m35s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 1m15s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 1m16s
CI/CD Pipeline / CI Gate (pull_request) Successful in 55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m17s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m6s
AI Code Review / AI Code Review (pull_request) Failing after 11m37s
CI/CD Pipeline / Integration Tests (push) Successful in 2m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m52s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 11:46:04 +08:00
CI Bot b69588f14d 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 / 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 3m22s
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 Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 4m45s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m10s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m44s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m36s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m36s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m9s
CI/CD Pipeline / CI Gate (pull_request) Successful in 48s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m34s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m33s
2026-08-30 03:38:42 +00:00
xiaoxia 1f9236bb16 fix(ingest-jobs): GET /{job_id} 查不到时返回 404 而非 500 ValueError
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 5s
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 3m45s
AI Code Review / AI Code Review (pull_request) Successful in 4m22s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m33s
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 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 / Deploy Staging (Watchtower auto-deploy) (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 / Staging API Integration Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m35s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m37s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m33s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m53s
CI/CD Pipeline / Validate - Code Quality (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 / 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
之前 job 不存在抛 ValueError,被全局异常处理映射到 500,导致客户端拿不到合理的
'未找到'语义。改为 HTTPException(404)。

补 GET 路由单测:不存在→404、存在→200 + 字段完整。
2026-08-30 11:27:27 +08:00
xiaoxia 411595ed90 ci: 测试类job去重——同head_sha有push流水线时PR侧跳过测试 (#1551)
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 / Frontend Lint (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 / 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 (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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (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 / Check if frontend-only change (pull_request) Successful in 4m12s
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) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m17s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4m37s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m42s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m53s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m46s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m49s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 48s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m11s
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) Successful in 28s
AI Code Review / AI Code Review (pull_request) Successful in 7m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m40s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 3m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m23s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m9s
CI/CD Pipeline / Unit Tests (push) Successful in 14m27s
CI/CD Pipeline / Build Production API 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 / Build Production Worker Image (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-08-30 10:56:00 +08:00
xiaoxia 839426a2cc fix: 素材余量四字段兼容 Asset 领域实体(真实接口恒为 None 修复) (#1550)
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 / 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 / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m32s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m41s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m7s
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 / 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m47s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m47s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 7m21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m27s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m7s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m59s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m5s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m57s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m10s
CI/CD Pipeline / Integration Tests (push) Successful in 4m49s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m52s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m31s
CI/CD Pipeline / Unit Tests (push) Successful in 18m20s
CI/CD Pipeline / Build Production API 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 / Build Production Worker Image (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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m36s
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 / Canary Release to 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 24s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 08:21:00 +08:00
xiaoxia 3acdb8b729 Merge pull request 'feat: 片段重复率控制(受控复用+15%双闸门+批量plan独立)与素材余量四字段' (#1549) from feat/segment-duplication-control into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m53s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m53s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4m22s
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 / 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 6m35s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 7m9s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 7m15s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m18s
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 / PR Build Worker Image (pull_request) Successful in 1m26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m24s
AI Code Review / AI Code Review (pull_request) Failing after 8m40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m46s
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) Successful in 34s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m51s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m55s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m29s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m6s
CI/CD Pipeline / Integration Tests (push) Successful in 4m7s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m27s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m10s
CI/CD Pipeline / Unit Tests (push) Successful in 16m39s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m38s
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 / Canary Release to 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 24s
2026-08-30 07:36:33 +08:00
CI Bot 8c760d4a2d 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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / 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 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 / Validate - Type Check (mypy) (pull_request) Successful in 2m39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m16s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m32s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m34s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m25s
AI Code Review / AI Code Review (pull_request) Successful in 9m23s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m25s
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 / Canary Release to 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 46s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m30s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m53s
2026-08-29 23:21:59 +00:00
CI Bot 78e9463825 fix(segments): AI Review 二轮修复 — 批量无源plan索引守卫 + 区间set精度归一化
CI/CD Pipeline / Check push changed paths (pull_request) 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 / Retag skipped 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m8s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m36s
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 / Validate - Migration (alembic) (pull_request) Successful in 4m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m23s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m29s
CI/CD Pipeline / Validate - Code Quality (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 / 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
- [阻塞] count>1 但 source_edit_plan_id 为空时 variant_plan_ids 为空列表,
  循环 task_index>0 直接索引 variant_plan_ids[task_index-1] 抛 IndexError
  导致批量创建崩溃;加非空守卫,无源 plan 时各任务走自身随机选片
  (新增回归测试 test_batch_no_plan_guard:还原守卫即红、修复后绿)
- [建议] MediaKit 历史区间 set 去重前 round(3) 归一化浮点精度,避免
  1.0 与 1.0000000001 被误判为不同区间
- [建议] smart-match 循环调用 compute_asset_availability 为纯函数
  (只读 model 内存字段、无 DB 查询),无 N+1 问题,无需修改
2026-08-30 07:14:16 +08:00
CI Bot b2d4589949 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 / 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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging 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 / Check if frontend-only change (pull_request) Successful in 3m6s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m8s
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 / Validate - Migration (alembic) (pull_request) Successful in 3m51s
AI Code Review / AI Code Review (pull_request) Successful in 3m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m4s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m0s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m21s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m9s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m27s
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) Failing after 24s
2026-08-29 18:38:42 +00:00
CI Bot b2a334f99b fix(segments): AI Review 阻塞项修复 — 记录写入行级锁 + 克隆失败不降级共用
CI/CD Pipeline / Check push changed paths (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 Staging API 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 / ACR Image Cleanup (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 / Check if frontend-only change (pull_request) Successful in 2m52s
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 / Validate - Type Check (mypy) (pull_request) Successful in 3m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m42s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m52s
AI Code Review / AI Code Review (pull_request) Failing after 4m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m34s
CI/CD Pipeline / Validate - Code Quality (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 / 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 Approve on CI Green (pull_request) Has been cancelled
- record_used_segments 读取 AssetModel 加 with_for_update() 行级锁,
  序列化并发事务对 classification_result 的读-改-写,杜绝区间记录丢失更新
  (SQLite 下 with_for_update 为 no-op)
- 批量生成:变体 plan 克隆前置到任务创建之前,全部成功才建任务(无脏数据);
  克隆含 1 次重试抗 DB 抖动,仍失败则 500 中断,不再静默退回共用源 plan
- 预览生成:克隆失败标记任务 failed 并返回 500,不再退回原 plan
  (两条修复共同保证批量/多预览视频内容不重复的业务约束)
- 测试:FakeSession 支持 with_for_update;预览相关测试 mock EditPlanService
  或显式置空 source_edit_plan_id;zip 加 strict(B905)
2026-08-30 02:33:19 +08:00
CI Bot 7cb36a5c3d 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 / 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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging 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 / Check if frontend-only change (pull_request) Successful in 3m9s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m43s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m3s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m24s
AI Code Review / AI Code Review (pull_request) Failing after 4m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m49s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m19s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 24s
2026-08-29 18:16:26 +00:00
CI Bot e33502956c feat(segments): 片段重复率控制(受控复用+15%闸门)与素材余量四字段
CI/CD Pipeline / Check push changed paths (pull_request) 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 / 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 / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m5s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m38s
AI Code Review / AI Code Review (pull_request) Failing after 3m41s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m24s
CI/CD Pipeline / Validate - Code Quality (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 / 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 Approve on CI Green (pull_request) Has been cancelled
Task G:任意两条成片画面重复率 ≤15%
- MediaKit 智能选片避让历史区间:历史区间并入冲突检查、0.3s 边缘间隙、
  撞历史时放弃推荐保留原起点(不再挪到已用画面)
- 自动轮回改造为受控复用:历史永不自动清空;used_time_ranges 增加
  use_count/last_used_at;100 次随机失败后复用 last_used_at 最老且
  use_count<3 的区间;单条成片复用占比 >15% 时该素材停止分配(分配闸门 +
  复用回调内预判双闸门);全部素材超限返回 400「素材可切区间不足,请补充新素材」
- 复用回调返回 None 时 calc 直接返回 None(删除末尾/0.0 重叠降级),
  from-assets 轮询内 calc 失败切换下一素材
- 批量 N 条视频片段独立:count>1 时克隆独立 plan,禁止共用
  source_edit_plan_id/clips(合并 Issue #1282);预览同样克隆独立变体
- 常量:REUSE_RATIO_LIMIT=0.15 / SEGMENT_EDGE_GAP=0.3 / MAX_RANGE_USE_COUNT=3

Task H:素材余量透出与已用尽素材排除
- GET /assets 列表与详情响应附加 used_duration/available_duration/
  used_ratio/usable 四字段(非视频/无时长/异常时 None + usable=True,零影响)
- usable=false:零重复可切区间已耗尽(扩边后无 ≥3s 空闲段)且所有历史
  区间 use_count 均达上限
- smart-match 结果层过滤 usable=false 素材(评分逻辑不动)
- from-assets 对已用尽素材天然兜底:calc 返回 None → 轮询跳过 →
  全部素材用尽时 400

测试:新增 test_mediakit_conflicts(10)/test_clone_plan_variant(7)/
test_asset_availability(23),更新 tracker/clips/mediakit 既有用例;
全量 unit 13910 passed
2026-08-30 02:10:06 +08:00
xiaoxia 652bbfe12b feat(assets): 素材余量角标 + Step2 过滤已用尽素材 (#1548)
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 (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m26s
CI/CD Pipeline / Check push changed paths (push) Successful in 4m27s
PR Automation / Auto Merge on CI Green + Approved (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 / Validate - Migration (alembic) (push) Successful in 5m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m58s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m5s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 6m9s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m19s
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 / PR Build API Image (pull_request) Successful in 1m42s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m4s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 45s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m9s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m22s
AI Code Review / AI Code Review (pull_request) Successful in 8m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m17s
CI/CD Pipeline / Integration Tests (push) Successful in 5m10s
CI/CD Pipeline / Unit Tests (push) Successful in 16m2s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m29s
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 / Canary Release to 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 23s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 01:26:00 +08:00
xiaoxia 6422472ef3 feat(voices): 配音素材卡片 UI 对齐预置音色 + 删除常驻 + 批量删除 (#1545)
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 / Check if frontend-only change (pull_request) Successful in 3m48s
PR Automation / Auto Merge on CI Green + Approved (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 / 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 5m39s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m21s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 7m33s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m15s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m39s
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 / Build Staging API Image (push) 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 / Build 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
CI/CD Pipeline / Build Staging Web Image (push) Successful in 28s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 49s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 55s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 10m41s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 10m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m41s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 12m34s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 12m37s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m49s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m21s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 20m11s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 20m35s
CI/CD Pipeline / Unit Tests (push) Successful in 30m9s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 29m30s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 22m50s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 22m34s
CI/CD Pipeline / CI Gate (pull_request) Successful in 26s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-29 23:21:00 +08:00
xiaoxia 0a061af582 feat(voices): 配音素材卡片真实播放 + 预置/克隆音色 TTS 试听 (#1544)
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m6s
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 4m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m13s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m31s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m48s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 8m52s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 51s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 52s
CI/CD Pipeline / Build 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
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 49s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 49s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 12m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m54s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 13m21s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 14m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m9s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 15m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 33m45s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 33m52s
CI/CD Pipeline / Unit Tests (push) Successful in 43m54s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 41m52s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 26m49s
CI/CD Pipeline / Integration Tests (push) Successful in 26m43s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m49s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-29 21:56:00 +08:00
xiaoxia f655508f88 Merge pull request 'feat: 素材片段区间持久化去重 + 区间用尽自动轮回' (#1543) from feat/persist-used-segments into develop
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 / Check push changed paths (push) Successful in 3m59s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m34s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m20s
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 (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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m32s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 40s
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 Worker Image (push) Successful in 43s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 42s
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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 37s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) 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 / Retag skipped Staging Web Image (push) Successful in 29s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 11m4s
AI Code Review / AI Code Review (pull_request) Failing after 11m4s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 11m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m51s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 12m40s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 13m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 21m39s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 22m1s
CI/CD Pipeline / Unit Tests (push) Successful in 29m55s
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 / Unit Tests (pull_request) Successful in 28m36s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-29 21:19:15 +08:00
CI Bot 1b83ec9952 fix: AI Code Review 阻塞问题修复
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 / 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 / Check if frontend-only change (pull_request) Successful in 3m10s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m15s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m10s
AI Code Review / AI Code Review (pull_request) Successful in 5m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m24s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 9m7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 10m0s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 18m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 21m17s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 19m16s
CI/CD Pipeline / CI Gate (pull_request) Successful in 28s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m5s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m30s
1. remove_used_segment 旧数据兼容:记录缺 plan_id(本功能上线前的旧数据)时
   退化为按时间匹配删除,避免旧区间永远删不掉导致素材容量泄漏
2. MediaKit 异步更新事务一致性:clip start_time 更新与 metadata 区间记录
   删旧/写新放入同一事务,metadata 失败时 rollback 本次 clip 更新,
   统一 commit,消除 clip 已提交、metadata 未更新的不一致
3. (建议) on_exhausted 回调异常增加 logger.warning,不再静默吞掉
4. 补充旧数据兼容单测
2026-08-29 20:38:56 +08:00
CI Bot 5d705307c1 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 / 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 / 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 / Check if frontend-only change (pull_request) Successful in 2m36s
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 1m31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m36s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m45s
AI Code Review / AI Code Review (pull_request) Failing after 7m6s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 9m5s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 10m19s
CI/CD Pipeline / Validate - Code Quality (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 / 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 / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-29 12:26:01 +00:00
CI Bot 2174e91c48 feat: 素材片段区间持久化去重 + 区间用尽自动轮回
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 / 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 / Check if frontend-only change (pull_request) Successful in 2m39s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m8s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m30s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 8m33s
AI Code Review / AI Code Review (pull_request) Successful in 8m50s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 9m35s
CI/CD Pipeline / Validate - Code Quality (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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
- 新增 asset_segment_tracker 服务:素材 metadata(used_time_ranges) 持久化片段级已用区间
- from-assets 创建片段时读取历史区间,新片段跨任务/跨调用自动避开
- 片段记录与 replace_all_clips_transactional 同事务,失败整体回滚
- MediaKit 异步移动片段起点后同步更新 metadata 区间记录(失败静默)
- _calc_random_start_time 新增 on_exhausted 回调:100次找不到时清空该素材历史区间再重试,实现轮完一圈自动循环

详见 PR body 的 metadata schema 说明
2026-08-29 20:14:28 +08:00
xiaoxia 700d6f9130 Merge pull request 'chore: 删除素材选取 random 模式,保持代码干净' (#1542) from chore/remove-random-asset-mode into develop
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 3m58s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m40s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m38s
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 - Migration (alembic) (pull_request) Successful in 2m55s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m43s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m26s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m27s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 55s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m32s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 1m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m20s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m58s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m13s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m15s
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
CI/CD Pipeline / Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m21s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m24s
CI/CD Pipeline / Unit Tests (push) Successful in 16m11s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m20s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m20s
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 / Canary Release to 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 27s
2026-08-29 18:14:50 +08:00
147 changed files with 9772 additions and 3075 deletions
+278 -148
View File
@@ -24,6 +24,55 @@ concurrency:
group: ci-pipeline-${{ gitea.ref }}
cancel-in-progress: true
jobs:
dedupe-check:
name: Dedup Check - skip PR tests when covered by push pipeline
runs-on: ci-l1
timeout-minutes: 3
outputs:
skip_tests: ${{ steps.dedupe.outputs.skip_tests }}
reason: ${{ steps.dedupe.outputs.reason }}
steps:
- name: Decide test dedup
id: dedupe
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -eu
if [ "$EVENT_NAME" != "pull_request" ]; then
echo "skip_tests=false" >> $GITHUB_OUTPUT
echo "reason=push-event-tests-required" >> $GITHUB_OUTPUT
echo "push 事件:测试照跑(部署链路门禁必需)"
exit 0
fi
# 情形1:PR 已合并(合并瞬间/合并后触发的 PR run)-> 全量测试由 push 流水线承接
MERGED=$(curl -sfH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('merged') else 'false')" || echo false)
if [ "$MERGED" = "true" ]; then
echo "skip_tests=true" >> $GITHUB_OUTPUT
echo "reason=pr-merged-push-pipeline-covers" >> $GITHUB_OUTPUT
echo "::warning::PR #${PR_NUMBER} 已合并,测试由合并后 push 流水线承接,PR 侧测试类 job 跳过"
exit 0
fi
# 情形2:同一 head_sha 已有在跑/排队/已成功的 push 流水线(rebase/ff 合并竞态;
# 已成功也去重——竞态窗口内两边都通过判断时,PR侧再跑全量测试纯属重复)
DUP=$(curl -sfH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${HEAD_SHA}&per_page=30" \
| python3 -c "import json,sys; d=json.load(sys.stdin); runs=d if isinstance(d,list) else d.get('workflow_runs',d.get('runs',[])); hit=[r for r in runs if r.get('event')=='push' and (r.get('status') in ('in_progress','queued','waiting','pending') or r.get('conclusion')=='success')]; print('true' if hit else 'false')" || echo false)
if [ "$DUP" = "true" ]; then
echo "skip_tests=true" >> $GITHUB_OUTPUT
echo "reason=duplicate-push-run-active" >> $GITHUB_OUTPUT
echo "::warning::同一 head_sha ${HEAD_SHA:0:8} 已有 push 流水线在跑,PR 侧测试类 job 跳过"
exit 0
fi
echo "skip_tests=false" >> $GITHUB_OUTPUT
echo "reason=no-duplicate" >> $GITHUB_OUTPUT
echo "无重复 push 流水线,PR 侧测试照跑"
check-frontend-only:
name: Check if frontend-only change
runs-on: ci-l2
@@ -32,12 +81,6 @@ jobs:
skip_backend: ${{ steps.check.outputs.skip_backend }}
skip_frontend: ${{ steps.check.outputs.skip_frontend }}
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Check changed files
id: check
shell: bash
@@ -76,12 +119,14 @@ jobs:
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
validate-code-quality:
name: Validate - Code Quality
validate-style:
needs: dedupe-check
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
name: Validate - Style
runs-on: ci-l2
timeout-minutes: 8
timeout-minutes: 6
env:
PIP_CACHE_DIR: /root/.cache/pip
PIP_NO_CACHE_DIR: ''
@@ -94,16 +139,23 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Cache pip dependencies
uses: actions/cache@v4
continue-on-error: true
with:
path: /root/.cache/pip
key: ${{ runner.os }}-pip-codequality-${{ hashFiles('requirements*.txt') }}
key: ${{ runner.os }}-pip-style-${{ hashFiles('requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-codequality-
${{ runner.os }}-pip-style-
${{ runner.os }}-pip-
- name: Install dependencies
shell: sh
@@ -127,17 +179,9 @@ jobs:
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1 && break
echo "pip install black/isort 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run code quality and security checks
- name: Run style checks
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash scripts/ci/validate_code_quality.sh
run: bash scripts/ci/validate_style.sh
- name: Auto-fix formatting (black + isort)
if: failure()
shell: sh
@@ -153,7 +197,7 @@ jobs:
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: |
set +e
FAILED_JOB="Validate - Code Quality" python3 scripts/ci_notify_failure.py
FAILED_JOB="Validate - Style" python3 scripts/ci_notify_failure.py
- name: Job duration summary
if: always()
shell: sh
@@ -166,7 +210,7 @@ jobs:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Validate - Code Quality" python3 scripts/ci_notify.py
NOTIFY_MODE=failure JOB_NAME="Validate - Style" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
@@ -179,10 +223,16 @@ jobs:
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
validate-type-check:
name: Validate - Type Check (mypy)
validate-security:
needs: dedupe-check
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
name: Validate - Security
runs-on: ci-l2
timeout-minutes: 8
env:
PIP_CACHE_DIR: /root/.cache/pip
PIP_NO_CACHE_DIR: ''
permissions:
contents: read
steps:
@@ -192,9 +242,125 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Cache pip dependencies
uses: actions/cache@v4
continue-on-error: true
with:
path: /root/.cache/pip
key: ${{ runner.os }}-pip-security-${{ hashFiles('requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-security-
${{ runner.os }}-pip-
- name: Install dependencies
shell: sh
run: |
set -eu
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run security checks
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash scripts/ci/validate_security.sh
- name: CI failure notification
if: failure()
shell: sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: |
set +e
FAILED_JOB="Validate - Security" python3 scripts/ci_notify_failure.py
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Validate - Security" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
validate-python:
needs: dedupe-check
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
name: Validate - Python (mypy + alembic)
runs-on: ci-l2
timeout-minutes: 10
env:
PIP_CACHE_DIR: /root/.cache/pip
PIP_NO_CACHE_DIR: ''
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
permissions:
contents: read
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Cache pip dependencies
uses: actions/cache@v4
continue-on-error: true
with:
path: /root/.cache/pip
key: ${{ runner.os }}-pip-python-${{ hashFiles('requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-python-
${{ runner.os }}-pip-
- name: Install dependencies
shell: sh
run: |
@@ -220,82 +386,6 @@ jobs:
- name: Run mypy type check
shell: bash
run: bash scripts/ci/validate_mypy.sh
- name: CI failure notification
if: failure()
shell: sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: |
set +e
FAILED_JOB="Validate - Type Check (mypy)" python3 scripts/ci_notify_failure.py
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Validate - Type Check (mypy)" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
validate-migration:
name: Validate - Migration (alembic)
runs-on: ci-l2
timeout-minutes: 8
permissions:
contents: read
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Install dependencies
shell: sh
run: |
set -eu
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
- name: Run alembic migration validation
shell: bash
run: bash scripts/ci/validate_migration.sh
@@ -307,7 +397,7 @@ jobs:
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: |
set +e
FAILED_JOB="Validate - Migration (alembic)" python3 scripts/ci_notify_failure.py
FAILED_JOB="Validate - Python (mypy + alembic)" python3 scripts/ci_notify_failure.py
- name: Job duration summary
if: always()
shell: sh
@@ -320,7 +410,7 @@ jobs:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Validate - Migration (alembic)" python3 scripts/ci_notify.py
NOTIFY_MODE=failure JOB_NAME="Validate - Python (mypy + alembic)" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
@@ -333,9 +423,10 @@ jobs:
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
unit-tests:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
needs: [check-frontend-only, dedupe-check]
if: always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_backend != 'true'
name: Unit Tests
runs-on: ci-l2
timeout-minutes: 8
@@ -354,6 +445,12 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
@@ -362,6 +459,7 @@ jobs:
run: bash scripts/ci/step_install_ffmpeg.sh
- name: Cache pip dependencies
uses: actions/cache@v4
continue-on-error: true
with:
path: /root/.cache/pip
key: ${{ runner.os }}-pip-unittests-${{ hashFiles('requirements*.txt') }}
@@ -412,12 +510,10 @@ jobs:
name: Integration Tests
runs-on: ci-l2
timeout-minutes: 30
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
if: always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_backend != 'true'
needs:
- check-frontend-only
- validate-code-quality
- validate-type-check
- validate-migration
- dedupe-check
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
@@ -433,6 +529,12 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
@@ -478,8 +580,8 @@ jobs:
name: Frontend Lint
runs-on: ci-l2
timeout-minutes: 10
needs: check-frontend-only
if: needs.check-frontend-only.outputs.skip_frontend != 'true'
needs: [check-frontend-only, dedupe-check]
if: needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_frontend != 'true'
steps:
- name: Checkout code
shell: sh
@@ -487,6 +589,12 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
@@ -540,8 +648,8 @@ jobs:
name: Frontend Unit Tests
runs-on: ci-l2
timeout-minutes: 15
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_frontend != 'true'
needs: [check-frontend-only, dedupe-check]
if: always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_frontend != 'true'
steps:
- name: Checkout code
shell: sh
@@ -549,11 +657,18 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Cache npm dependencies
uses: actions/cache@v4
continue-on-error: true
with:
path: /root/.npm
key: ${{ runner.os }}-npm-vitest-${{ hashFiles('apps/web/package-lock.json') }}
@@ -640,6 +755,12 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
@@ -754,19 +875,13 @@ jobs:
skip_backend: ${{ steps.check.outputs.skip_backend }}
skip_frontend: ${{ steps.check.outputs.skip_frontend }}
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Check changed paths
id: check
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
bash scripts/ci/ci_push_paths.sh
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_push_paths.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_push_paths.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_push_paths.sh
- name: Report CI trace
if: always()
shell: sh
@@ -777,7 +892,7 @@ jobs:
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
build-staging:
name: Build Staging ${{ matrix.service_display }} Image
@@ -818,6 +933,12 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
@@ -1029,9 +1150,7 @@ jobs:
- check-push-paths
- build-staging
- retag-staging-skipped
# 显式 success() 状态检查:上游 build/retag 被路径过滤 if 跳过(skipped)时不阻塞本 job
# 上游真正失败时仍然阻断(act_runner 对无状态函数的 if 隐式包 success(),纯 skipped 也会连带跳过)
if: success() && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
if: (!cancelled()) && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
steps:
- name: Checkout code
shell: sh
@@ -1191,6 +1310,12 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
@@ -1238,6 +1363,12 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
@@ -1277,8 +1408,9 @@ jobs:
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
needs:
- validate-code-quality
- validate-type-check
- validate-style
- validate-security
- validate-python
- unit-tests
- frontend-lint
- frontend-unit-test
@@ -1312,6 +1444,12 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Zombie run selfcheck
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: bash scripts/ci/ci_run_selfcheck.sh
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
@@ -1758,9 +1896,9 @@ jobs:
if: always() && github.event_name == 'pull_request'
needs:
- check-frontend-only
- validate-code-quality
- validate-type-check
- validate-migration
- validate-style
- validate-security
- validate-python
- unit-tests
- integration-tests
- frontend-lint
@@ -1768,14 +1906,6 @@ jobs:
- build-pr
timeout-minutes: 3
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Evaluate CI Gate
id: gate
shell: bash
@@ -1784,9 +1914,9 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RESULT_CHECK_FRONTEND: ${{ needs.check-frontend-only.result }}
RESULT_CODE_QUALITY: ${{ needs.validate-code-quality.result }}
RESULT_TYPE_CHECK: ${{ needs.validate-type-check.result }}
RESULT_MIGRATION: ${{ needs.validate-migration.result }}
RESULT_STYLE: ${{ needs.validate-style.result }}
RESULT_SECURITY: ${{ needs.validate-security.result }}
RESULT_PYTHON: ${{ needs.validate-python.result }}
RESULT_UNIT_TESTS: ${{ needs.unit-tests.result }}
RESULT_INTEGRATION: ${{ needs.integration-tests.result }}
RESULT_FRONTEND_LINT: ${{ needs.frontend-lint.result }}
@@ -1798,9 +1928,9 @@ jobs:
echo ""
echo "各job结果:"
echo " check-frontend-only: $RESULT_CHECK_FRONTEND"
echo " validate-code-quality: $RESULT_CODE_QUALITY"
echo " validate-type-check: $RESULT_TYPE_CHECK"
echo " validate-migration: $RESULT_MIGRATION"
echo " validate-style: $RESULT_STYLE"
echo " validate-security: $RESULT_SECURITY"
echo " validate-python: $RESULT_PYTHON"
echo " unit-tests: $RESULT_UNIT_TESTS"
echo " integration-tests: $RESULT_INTEGRATION"
echo " frontend-lint: $RESULT_FRONTEND_LINT"
@@ -1840,9 +1970,9 @@ jobs:
# 必填检查项(根据PR类型决定)
# 通用检查(所有PR都必须过)
REQUIRED_GENERAL=(
"validate-code-quality:$RESULT_CODE_QUALITY"
"validate-type-check:$RESULT_TYPE_CHECK"
"validate-migration:$RESULT_MIGRATION"
"validate-style:$RESULT_STYLE"
"validate-security:$RESULT_SECURITY"
"validate-python:$RESULT_PYTHON"
"frontend-lint:$RESULT_FRONTEND_LINT"
"build-pr:$RESULT_BUILD_PR"
"ai-code-review:$AI_REVIEW_STATUS"
@@ -1937,4 +2067,4 @@ jobs:
[ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+2 -2
View File
@@ -2,13 +2,13 @@ name: CI Trigger Monitor
on:
schedule:
- cron: '*/5 * * * *' # 每5分钟检查一次
- cron: '*/10 * * * *' # 每10分钟检查一次(与pr-auto-scan同步降频)
workflow_dispatch:
inputs:
stale_threshold:
description: 'CI未触发告警阈值(分钟)'
required: false
default: '5'
default: '10'
permissions:
contents: read
+1 -1
View File
@@ -3,7 +3,7 @@ name: PR Auto Scan
# 作为短作业模式的兜底,防止事件驱动遗漏
on:
schedule:
- cron: "*/5 * * * *" # 每5分钟扫描一次
- cron: "*/10 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
workflow_dispatch:
permissions:
@@ -0,0 +1,49 @@
"""Add unique index on asset_libraries(project_id, kind)
Revision ID: 058_uq_asset_lib_project_kind
Revises: 057_title_config
Create Date: 2026-08-30
同一项目下同 kind 的素材库业务上唯一(前端 getOrCreate 语义、TTS 保存自动建库)。
加唯一索引兜底并发创建竞态,避免重复素材库。
"""
import sqlalchemy as sa
from alembic import op
revision = "058_uq_asset_lib_project_kind"
down_revision = "057_title_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 建唯一索引前清洗历史重复:同 (project_id, kind) 只保留 created_at 最新的一条。
# project_id 为 NULL 的系统级行不参与去重(NULL 在唯一索引中互不冲突)。
op.execute("""
DELETE FROM asset_libraries
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY project_id, kind
ORDER BY created_at DESC, id DESC
) AS rn
FROM asset_libraries
WHERE project_id IS NOT NULL
) t
WHERE t.rn > 1
)
""")
# 与 model 的 UniqueConstraint 定义保持一致(pg_constraint + pg_index 同时注册),
# 避免 Alembic autogenerate 检测到 schema drift
op.create_unique_constraint(
"uq_asset_libraries_project_kind",
"asset_libraries",
["project_id", "kind"],
)
def downgrade() -> None:
op.drop_constraint("uq_asset_libraries_project_kind", "asset_libraries", type_="unique")
@@ -0,0 +1,23 @@
"""add duplicate_rate to generated_videos
Revision ID: 059_duplicate_rate
Revises: 058_uq_asset_lib_project_kind
Create Date: 2026-08-31
"""
import sqlalchemy as sa
from alembic import op
revision = "059_duplicate_rate"
down_revision = "058_uq_asset_lib_project_kind"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("generated_videos", sa.Column("duplicate_rate", sa.Float(), nullable=True))
def downgrade() -> None:
op.drop_column("generated_videos", "duplicate_rate")
@@ -0,0 +1,57 @@
"""migrate template_segments data to template_clip_configs
Revision ID: 060_migrate_segments
Revises: 059_duplicate_rate
Create Date: 2026-08-31
"""
import sqlalchemy as sa
from alembic import op
revision = "060_migrate_segments"
down_revision = "059_duplicate_rate"
branch_labels = None
depends_on = None
def upgrade() -> None:
dialect = op.get_bind().dialect.name
if dialect == "postgresql":
config_expr = (
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
"THEN json_build_object('material_type', s.material_type)::jsonb "
"ELSE '{}'::jsonb END"
)
empty_json = "'{}'::jsonb"
else:
config_expr = (
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
"THEN JSON_OBJECT('material_type', s.material_type) "
"ELSE '{}' END"
)
empty_json = "'{}'"
sql_str = (
"INSERT INTO template_clip_configs "
'(id, template_id, clip_type, "order", min_duration, max_duration, '
"text_template, material_requirements, transition_effect, config, "
"created_at, updated_at) "
"SELECT "
"s.id, s.template_id, 'main', s.segment_order, "
"s.duration_min, s.duration_max, "
"'', " + empty_json + ", "
"'cut', " + config_expr + ", "
"s.created_at, s.updated_at "
"FROM template_segments s "
"WHERE NOT EXISTS ("
" SELECT 1 FROM template_clip_configs c "
" WHERE c.template_id = s.template_id"
")"
)
op.execute(sa.text(sql_str))
def downgrade() -> None:
pass
+78 -5
View File
@@ -26,6 +26,7 @@ from app.schemas.asset import (
UpdateAssetReviewRequest,
)
from app.schemas.tag import TagAssetsRequest
from app.services.asset_segment_tracker import compute_asset_availability, get_asset_recent_use_counts
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from packages.domain.smart_match import smart_select_assets
@@ -35,6 +36,23 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _asset_availability_fields(item) -> dict:
"""视频素材返回余量四字段;非视频/无时长/异常时返回 None + usable=True(零影响)。"""
try:
info = compute_asset_availability(item)
except Exception:
logger.warning("计算素材余量失败,按可用处理: asset_id=%s", getattr(item, "id", "?"), exc_info=True)
info = None
if info is None:
return {
"used_duration": None,
"available_duration": None,
"used_ratio": None,
"usable": True,
}
return info
def _to_asset_response(item, storage_service=None) -> AssetResponse:
# 生成签名文件 URL(用于视频播放 / 文件下载)
file_url = None
@@ -79,6 +97,7 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
created_at=format_utc_datetime(item.created_at),
uploaded_by_user_id=item.uploaded_by_user_id,
tag_ids=getattr(item, "tag_ids", []),
**_asset_availability_fields(item),
)
@@ -557,9 +576,7 @@ def smart_match_assets(
request.library_id, request.kind, status=["ready"], limit=10000
)
else:
filtered_assets = asset_repository.find_by_library(
request.library_id, status=["ready"], limit=10000
)
filtered_assets = asset_repository.find_by_library(request.library_id, status=["ready"], limit=10000)
total_candidates = len(filtered_assets)
# 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
@@ -569,13 +586,69 @@ def smart_match_assets(
kind=None,
)
# 结果层过滤:usable=false(零重复可切区间耗尽且历史区间均达复用上限)的素材
# 不返回给前端;不动 smart_select_assets 评分逻辑本身
filtered_results = []
for r in results:
try:
avail = compute_asset_availability(r.asset)
except Exception:
logger.warning(
"smart-match 余量计算失败,按可用处理: asset_id=%s",
getattr(r.asset, "id", "?"),
exc_info=True,
)
avail = None
if avail is not None and not avail["usable"]:
logger.info(
"smart-match 排除已用尽素材: asset_id=%s name=%s",
getattr(r.asset, "id", "?"),
getattr(r.asset, "name", ""),
)
continue
filtered_results.append(r)
# 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
MAX_RECENT_USE_COUNT = 3
if filtered_results:
asset_ids = [getattr(r.asset, "id", "") for r in filtered_results if getattr(r.asset, "id", "")]
if asset_ids:
try:
use_counts = get_asset_recent_use_counts(
db=asset_repository.session,
asset_ids=asset_ids,
recent_video_count=5,
)
high_use_excluded = set()
for r in filtered_results:
aid = getattr(r.asset, "id", "")
count = use_counts.get(aid, 0)
if count > MAX_RECENT_USE_COUNT:
logger.info(
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
aid, count, MAX_RECENT_USE_COUNT,
)
high_use_excluded.add(id(r))
else:
pass
# 如果排除后不够 limit,放宽到不限制
remaining = [r for r in filtered_results if id(r) not in high_use_excluded]
if len(remaining) >= request.limit:
filtered_results = remaining
else:
logger.info("smart-match 高频排除后素材不足(%d<%d),保留全部", len(remaining), request.limit)
except Exception:
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
# 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层,
# 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读
items = [
SmartMatchItem(
asset=_to_asset_response(r.asset),
**_to_asset_response(r.asset).model_dump(),
score=r.score,
breakdown=r.breakdown,
)
for r in results
for r in filtered_results
]
return SmartMatchResponse(items=items, total_candidates=total_candidates)
@@ -358,6 +358,41 @@ def create_preview_generation_task(
exc_info=True,
)
# 每条预览都关联独立克隆 plan:多预览前端为 N 次并发调用,若共用同一 plan
# 则 N 条预览片段完全相同;克隆时片段起点按持久化历史区间重算(含受控复用),
# 保证各预览版本内容不同
if task.source_edit_plan_id:
try:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
_preview_plan = _plan_svc.clone_plan_for_variant(
task.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix="预览变体",
)
task.source_edit_plan_id = _preview_plan.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 预览关联独立克隆 plan: task_id=%s clone_plan_id=%s",
task.id,
_preview_plan.id,
)
except Exception as clone_err:
# 不退回共用原 plan(否则多条预览内容相同,违反去重诉求):
# 标记任务失败并中断,前端可重新发起预览
logger.error(
"[预览生成] 克隆预览变体 plan 失败,任务标记失败: task_id=%s error=%s",
task.id,
clone_err,
exc_info=True,
)
_mark_task_failed(generation_task_repository, task, "预览变体计划创建失败")
raise HTTPException(
status_code=500,
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
) from clone_err
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
try:
if not safe_enqueue_generation_task(
+50 -2
View File
@@ -418,8 +418,56 @@ def create_generation_task(
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
effective_strategy_id = "one_take"
# 批量生成时每个任务关联独立克隆 plan(片段起点重算),
# 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。
# 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据),
# 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。
variant_plan_ids: list[str] = []
if count > 1 and request.source_edit_plan_id:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
for task_index in range(1, count):
variant = None
last_err: Exception | None = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant = _plan_svc.clone_plan_for_variant(
request.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}",
)
break
except Exception as clone_err: # noqa: PERF203
last_err = clone_err
logger.warning(
"[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s",
_attempt + 1,
request.source_edit_plan_id,
clone_err,
exc_info=True,
)
if variant is None:
logger.error(
"[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s",
request.source_edit_plan_id,
exc_info=last_err,
)
raise HTTPException(
status_code=500,
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant.id)
try:
for _ in range(count):
for task_index in range(count):
# 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。
# 无源 plansource_edit_plan_id 为空)时无可克隆对象,variant_plan_ids
# 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError)
effective_plan_id = request.source_edit_plan_id
if task_index > 0 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index - 1]
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
@@ -431,7 +479,7 @@ def create_generation_task(
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=user_id,
source_edit_plan_id=request.source_edit_plan_id,
source_edit_plan_id=effective_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
video_title=request.video_title,
+2 -2
View File
@@ -3,7 +3,7 @@ from typing import Any
from app.core.celery_app import celery_app
from app.dependencies import get_ingest_job_repository
from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
@@ -17,7 +17,7 @@ def get_ingest_job(
) -> IngestJobResponse:
job = ingest_job_repository.get(job_id)
if job is None:
raise ValueError(f"IngestJob {job_id} not found")
raise HTTPException(status_code=404, detail=f"IngestJob {job_id} not found")
return IngestJobResponse(
id=job.id,
project_id=job.project_id,
+148 -59
View File
@@ -23,6 +23,14 @@ import re
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_asset_repository, get_db_session
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
SEGMENT_EDGE_GAP,
get_used_segments,
make_reuse_callback,
record_used_segments,
remove_used_segment,
)
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
@@ -359,7 +367,6 @@ def batch_delete_editor_clips(
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
def _safe_segment_duration(value, default: float) -> float:
"""安全地将数据库中的时长值转换为正浮点数.
@@ -395,9 +402,7 @@ def _get_template_segments(
if clip_configs:
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(
cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION
)
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,
@@ -430,11 +435,16 @@ def _recommended_time_conflicts(
start: float,
duration: float,
used: list[tuple[float, float]],
edge_gap: float = SEGMENT_EDGE_GAP,
) -> bool:
"""检查推荐起始时间是否与已使用时间段冲突."""
"""检查推荐起始时间是否与已使用时间段冲突.
冲突检测统一加 ``edge_gap`` 秒边缘间隙:已用区间按 [s-gap, e+gap] 扩边后判定,
避免推荐片段与已用片段首尾紧贴导致画面观感重复。
"""
end = start + duration
for used_start, used_end in used:
if start < used_end and end > used_start:
if start < used_end + edge_gap and end > used_start - edge_gap:
return True
return False
@@ -479,7 +489,7 @@ def _get_mediakit_recommendations(
prompt = (
"请分析每段视频,找出最精彩的5秒片段应该从哪个时间点开始。"
"考虑因素:画面清晰度、主体是否明确、是否有明显的动作或场景变化。"
'请严格以JSON数组格式返回,不要包含其他文字:'
"请严格以JSON数组格式返回,不要包含其他文字:"
'[{"asset_id": "素材ID", "recommended_start_time": 12.5, "reason": "原因"}]'
)
@@ -532,9 +542,7 @@ def _get_mediakit_recommendations(
# 尝试正则提取
if not parsed:
time_match = re.search(
r'recommended_start_time["\s:]+([\d.]+)', content_text
)
time_match = re.search(r'recommended_start_time["\s:]+([\d.]+)', content_text)
if time_match:
try:
recommendations[asset_id] = float(time_match.group(1))
@@ -585,14 +593,17 @@ def create_clips_from_assets_editor(
detail="模板没有片段配置,无法创建片段",
)
if not body.asset_ids:
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
# 避免异常入参(undefined → null)导致后续 /assets/{id} 404 / 422
asset_ids = [str(aid).strip() for aid in (body.asset_ids or []) if isinstance(aid, str) and aid.strip()]
if not asset_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材列表为空,无法创建片段",
)
# 2. 获取素材实际时长(去重查询)
unique_asset_ids = list(dict.fromkeys(body.asset_ids))
unique_asset_ids = list(dict.fromkeys(asset_ids))
asset_durations: dict[str, float] = {}
for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id)
@@ -600,49 +611,98 @@ def create_clips_from_assets_editor(
asset_durations[asset_id] = float(asset.duration or 0.0)
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
used_segments: dict[str, list[tuple[float, float]]] = {}
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
# 格式与 _calc_random_start_time 的 used_segments 参数一致
used_segments: dict[str, list[tuple[float, float]]] = get_used_segments(db, unique_asset_ids)
# 受控复用回调:可用区间耗尽时复用最久未用且未达复用上限(3次)的历史区间,
# 复用片段时长累加到 reused_durations 供 15% 占比控制
reused_durations: dict[str, float] = {}
# 本条成片中每个素材被分配的片段总时长(复用占比分母)
asset_assigned_durations: dict[str, float] = {}
# 受控复用回调:区间耗尽时复用最久未用且 use_count<3 的历史区间;
# 回调内部预判复用后占比是否超 15%,超限拒绝复用(返回 None)
reuse_cb = make_reuse_callback(
db,
asset_durations,
reused_durations,
assigned_tracker=asset_assigned_durations,
)
clips_data: list[dict] = []
def _reuse_ratio_exceeded(aid: str, extra: float = 0.0) -> bool:
"""该素材在本条成片中「已复用片段时长 / 已分配片段总时长」是否已超 15%
在为下一片段选素材时调用:本片段尚未分配,复用状态只在分配后的回调里
更新,因此直接检查当前占比——一旦已超 15%,该素材不再参与后续分配。
assigned=0(首个片段)放行;reused=0(尚未发生复用)时不误拦正常分配。
"""
assigned = asset_assigned_durations.get(aid, 0.0)
if assigned <= 0:
return False
return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
# 轮询分配素材
asset_id = body.asset_ids[i % len(body.asset_ids)]
asset_total = asset_durations.get(asset_id, 0.0)
# 素材时长为 0 或缺失时无法创建有效片段
if asset_total <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"素材 {asset_id} 时长信息缺失或为0,无法创建片段",
)
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
raw_duration = random.uniform(dur_min, dur_max)
clip_duration = round(raw_duration, 1)
# 素材时长不足时缩短 clip duration
clip_duration = min(clip_duration, asset_total)
# 轮询分配素材:跳过时长缺失、复用占比已超 15% 阈值的素材;
# 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(calc 返回 None),
# 继续轮询下一个素材
asset_id = ""
clip_duration = 0.0
start_time: float | None = None
n_assets = len(asset_ids)
for offset in range(n_assets):
candidate = asset_ids[(i + offset) % n_assets]
candidate_total = asset_durations.get(candidate, 0.0)
if candidate_total <= 0:
continue
candidate_duration = min(round(raw_duration, 1), candidate_total)
if candidate_duration <= 0:
continue
if _reuse_ratio_exceeded(candidate, candidate_duration):
logger.info(
"from-assets 素材复用占比超 %.0f%% 阈值,跳过分配: asset_id=%s",
REUSE_RATIO_LIMIT * 100,
candidate,
)
continue
# 随机起始时间(不调用 MediaKit,保证接口快速返回);100 次避不开
# 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
# 预判复用后占比超 15% 则拒绝并返回 None)
candidate_start = _calc_random_start_time(
candidate,
candidate_duration,
asset_durations,
used_segments,
on_exhausted=reuse_cb,
)
if candidate_start is None:
# 该素材可用区间耗尽且复用被闸门/use_count 上限拒绝 → 尝试下一素材
logger.info(
"from-assets 素材无可用可切区间(复用被拒),轮询下一素材: asset_id=%s",
candidate,
)
continue
asset_id = candidate
clip_duration = candidate_duration
start_time = candidate_start
break
if clip_duration <= 0:
if not asset_id or start_time is None:
# 所有素材时长缺失、复用占比超阈值,或区间耗尽且复用被拒 → 素材可切区间不足
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"素材 {asset_id} 时长不足,无法创建有效片段",
detail="素材可切区间不足,请补充新素材",
)
# 使用随机起始时间(不调用MediaKit,保证接口快速返回
start_time = _calc_random_start_time(
asset_id, clip_duration, asset_durations, used_segments
)
if start_time is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"素材 {asset_id} 时长信息缺失,无法计算起始时间",
)
# 记录已使用时间段
used_segments.setdefault(asset_id, []).append(
(start_time, start_time + clip_duration)
)
# 记录已使用时间段(内存,供本次后续片段避开
used_segments.setdefault(asset_id, []).append((start_time, start_time + clip_duration))
asset_assigned_durations[asset_id] = asset_assigned_durations.get(asset_id, 0.0) + clip_duration
# 同步写入素材 metadata(不 commit,与下方 replace_all_clips_transactional
# 处于同一事务,任一步失败整体回滚,不留脏数据);
# 复用区间与历史记录高度重叠时 record 内部自动累加 use_count
record_used_segments(db, asset_id, start_time, start_time + clip_duration, plan_id)
clips_data.append(
{
@@ -733,18 +793,18 @@ def _update_mediakit_recommendations_async( # pragma: no cover
# 批量预加载所有涉及的素材(消除 N+1 查询)
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
assets_map: dict[str, object] = {
a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)
}
assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)}
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环)
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
for clip in clips:
aid = getattr(clip, "asset_id", "") or ""
if aid and clip.start_time is not None:
clips_by_asset[aid].append(
(clip.id, clip.start_time, clip.start_time + clip.duration)
)
clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
historical_segments = get_used_segments(db, unique_asset_ids)
# 已更新的片段ID(用于排除已移动的旧时间段)
updated_clip_ids: set[str] = set()
@@ -788,10 +848,21 @@ def _update_mediakit_recommendations_async( # pragma: no cover
]
other_segments.extend(updated_segments.get(asset_id, []))
# 检查是否与同素材其他片段时间段冲突
# 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
# 本 plan 片段创建时已写入历史记录
# 并入该素材全部历史已用区间(含其他 plan/其他任务)。
# set 去重前先归一化精度(round 3 位),避免浮点尾差导致逻辑相同的
# 区间(如 1.0 与 1.0000000001)被误判为不同区间
def _norm(segs):
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
# 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
# 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
logger.info(
"后台任务: 推荐时间冲突,跳过: asset_id=%s recommended=%.2f",
"后台任务: 推荐时间与同片/历史区间冲突,保留原起点: asset_id=%s recommended=%.2f",
asset_id,
recommended_start,
)
@@ -799,23 +870,42 @@ def _update_mediakit_recommendations_async( # pragma: no cover
# 逐个更新并捕获异常(单点失败不影响其他片段)
try:
old_start = clip.start_time
old_end = old_start + clip_duration
# MediaKit 移动片段起点 + 同步素材 metadata 区间记录放在同一事务:
# 删旧区间记录(按 plan_id + 旧 start 匹配,兼容无 plan_id 的旧数据)、
# 写新区间,最后统一 commit;任一步失败整体 rollback
# 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
plan_svc.update_clip(clip.id, start_time=recommended_start)
try:
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
record_used_segments(
db,
asset_id,
recommended_start,
recommended_start + clip_duration,
plan_id,
)
except Exception as me:
logger.warning(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
clip.id,
me,
)
db.rollback()
continue
db.commit()
updated_count += 1
updated_clip_ids.add(clip.id)
except Exception as ue:
logger.warning(
"后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue
)
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
try:
db.rollback()
except Exception:
pass
continue
updated_segments.setdefault(asset_id, []).append(
(recommended_start, recommended_start + clip_duration)
)
updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration))
logger.info(
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
clip.id,
@@ -839,4 +929,3 @@ def _update_mediakit_recommendations_async( # pragma: no cover
db.close()
except Exception:
pass
@@ -167,7 +167,18 @@ class ClipsFromAssetsRequest(BaseModel):
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
clip_type: str = Field(default="main", description="片段类型,默认 main")
required_clips_count: Optional[int] = Field(default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量")
required_clips_count: Optional[int] = Field(
default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量"
)
@validator("asset_ids", pre=True)
def _drop_invalid_asset_ids(cls, v): # noqa: N805
"""容错过滤:前端异常情况下可能把 undefined 序列化成 null 或空串混入
asset_ids(会直接 422 或导致后续 /assets/{id} 404),这里统一剔除。
过滤后为空时由 Field(min_length=1) / 路由层 400 兜底。"""
if not isinstance(v, list):
return v
return [x for x in v if isinstance(x, str) and x.strip()]
class ClipsFromAssetsResponse(BaseModel):
+195 -59
View File
@@ -3,17 +3,21 @@
from __future__ import annotations
import logging
from typing import Optional
import tempfile
from pathlib import Path
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_audio_url_signer,
get_cosyvoice_service,
get_db_session,
get_user_repository,
get_project_repository,
get_voice_clone_profile_repository,
get_voice_library_repository,
)
from app.schemas.tts import (
ListTTSJobResponse,
@@ -27,12 +31,12 @@ from app.schemas.tts import (
TTSSynthesizeResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.tts_job_repository import (
SQLAlchemyTTSJobRepository,
)
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.tts_job.streaming_service import TTSStreamingService
from packages.application.tts_job.use_cases import (
@@ -44,13 +48,12 @@ from packages.application.tts_job.use_cases import (
TTSJobNotFoundError,
)
from packages.application.tts_job.workflow import TTSWorkflowService
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase,
QuotaExceededError,
)
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
from packages.domain.voice_presets import list_voices
from packages.ports.user_repository import UserRepository
from packages.ports.asset_library_repository import AssetLibraryRepository
from packages.ports.asset_repository import AssetRepository
from packages.ports.project_repository import ProjectRepository
from packages.shared.storage import SharedStorageService
logger = logging.getLogger(__name__)
@@ -134,27 +137,47 @@ def synthesize(
"""
user_id = authenticated_user.user.id
# 校验 voice_clone_profile_id 归属(防止越权使用他人克隆音色)
if request.voice_clone_profile_id:
profile = voice_clone_repo.get(request.voice_clone_profile_id)
if profile is None:
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id),
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id
actual_voice_id = request.voice_id
voice_clone_profile_id = request.voice_clone_profile_id
resolved_profile = None
if actual_voice_id:
resolved_profile = voice_clone_repo.get(actual_voice_id)
if resolved_profile is not None:
voice_clone_profile_id = actual_voice_id
# 显式传了 voice_clone_profile_id(且与 voice_id 不同)时再查一次归属
if voice_clone_profile_id and (resolved_profile is None or resolved_profile.id != voice_clone_profile_id):
resolved_profile = voice_clone_repo.get(voice_clone_profile_id)
if resolved_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Voice clone profile not found",
)
if profile.user_id != user_id:
if resolved_profile is not None:
if resolved_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to voice clone profile",
detail="无权访问该音色",
)
if not resolved_profile.voice_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="音色克隆尚未完成,请稍后再试",
)
# 命中克隆音色:无论 voice_id 直接传 profile UUID 还是显式传 voice_clone_profile_id
# job.voice_id 统一存解析后的 CosyVoice voice_id
actual_voice_id = resolved_profile.voice_id
use_case = CreateTTSJobUseCase(repository)
job = use_case.execute(
user_id=user_id,
input_text=request.text,
voice_id=request.voice_id,
voice_id=actual_voice_id,
voice_model=request.voice_model,
voice_clone_profile_id=request.voice_clone_profile_id,
voice_clone_profile_id=voice_clone_profile_id,
metadata=request.metadata_,
)
@@ -284,6 +307,62 @@ def delete_tts_job(
return
def _find_or_create_voice_library(
*,
user_id: str,
project_repository: ProjectRepository,
asset_library_repository: Any, # port Protocol 声明为 asyncSQLAlchemy 实现为同步,与 upload/asset_libraries 路由惯例一致用 Any
) -> AssetLibrary:
"""在用户可访问的项目中找到(或自动创建)voice 素材库。
与前端配音素材页逻辑一致:素材库挂在项目下,配音素材读取
getAssetsByKind("voice") → 用户所有可访问项目中的 voice 库。
优先使用已有 voice 库;没有则在第一个可访问项目中自动创建。
"""
projects = project_repository.find_accessible_projects(user_id)
if not projects:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有可用的项目,请先创建项目后再保存配音素材",
)
for project in projects:
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
return lib
# 所有项目都没有 voice 库 → 在第一个可访问项目中自动创建默认配音素材库。
# asset_libraries 有 (project_id, kind) 唯一索引兜底并发:若两个请求同时创建,
# 落败方捕获 IntegrityError 回滚后重新查询,返回抢先创建成功的库。
project = projects[0]
library = AssetLibrary.create(
project_id=project.id,
name="配音素材库",
kind=AssetLibraryKind.VOICE,
)
try:
return asset_library_repository.create(library)
except IntegrityError:
# 并发下另一个请求已抢先创建:回滚当前事务(立即 commit 模式下 session 已
# 自动回滚,rollback 为幂等 no-opUoW/flush 模式下必须显式回滚才能继续查询),
# 再重查返回抢先创建成功的库。
session = getattr(asset_library_repository, "session", None)
if session is not None:
try:
session.rollback()
except Exception:
logger.warning("IntegrityError 后回滚 session 失败(可能已关闭)", exc_info=True)
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
return lib
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="配音素材库创建失败,请重试",
) from None # IntegrityError 已处理,不保留异常链
@router.post(
"/jobs/{job_id}/save-to-library",
response_model=SaveToLibraryResponse,
@@ -294,13 +373,17 @@ def save_tts_job_to_library(
request: SaveToLibraryRequest = SaveToLibraryRequest(),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
user_repository: UserRepository = Depends(get_user_repository),
asset_repository: AssetRepository = Depends(get_asset_repository),
asset_library_repository: AssetLibraryRepository = Depends(get_asset_library_repository),
project_repository: ProjectRepository = Depends(get_project_repository),
storage_service: SharedStorageService = Depends(get_storage_service),
sign_url=Depends(get_audio_url_signer),
) -> SaveToLibraryResponse:
"""将已完成的 TTS 合成结果保存到配音
"""将已完成的 TTS 合成结果保存到配音素材库(assets 表新素材体系)
自动携带音色名、时长、语速等元信息。
流程:把 TTS 输出音频转存到用户素材 OSS 路径 → 创建 file_type=audio、
status=ready 的 asset(挂用户 voice 素材库)→ 返回前端可用结构。
配额策略与素材上传一致(上传/ingest 链路无额外配额拦截)。
"""
user_id = authenticated_user.user.id
@@ -318,64 +401,117 @@ def save_tts_job_to_library(
detail="TTS job is not completed yet",
)
# 构建配音素材名称
if not job.output_audio_url and not job.output_audio_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TTS job 缺少输出音频,无法保存",
)
# 素材名称
name = request.name or f"TTS-{job.id[:8]}"
# 构建元信息
metadata_ = {
# 找到(或自动创建)用户 voice 素材库
library = _find_or_create_voice_library(
user_id=user_id,
project_repository=project_repository,
asset_library_repository=asset_library_repository,
)
# 转存音频到素材 OSS 路径(tts-outputs/ 下的产物归 TTS 任务所有,
# 素材独立持有副本,删除 TTS 任务不影响配音库素材)
audio_format = (job.format or "mp3").strip() or "mp3"
content_type_map = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"pcm": "audio/pcm",
"opus": "audio/opus",
}
content_type = content_type_map.get(audio_format, "audio/mpeg")
storage_key = f"uploads/voice/tts/{job.id}.{audio_format}"
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp:
tmp_path = Path(tmp.name)
# 优先用 OSS storage_key(走 oss2 SDK,私有 bucket 也可下载);
# 兜底用 output_audio_url(旧任务可能没有 key)。
# download_asset 自动识别输入:http(s):// 开头走 HTTP 下载,否则按 OSS key 走 SDK。
download_source = job.output_audio_key or job.output_audio_url
downloaded = storage_service.download_asset(download_source, tmp_path)
if not downloaded or not tmp_path.exists() or tmp_path.stat().st_size == 0:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="TTS 音频下载失败,无法保存到配音库",
)
file_size = tmp_path.stat().st_size
storage_service.upload_file(tmp_path, storage_key, content_type=content_type)
except HTTPException:
raise
except Exception as e:
logger.error("TTS 音频转存素材失败: job_id=%s, error=%s", job.id, e, exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="TTS 音频转存失败,无法保存到配音库",
) from e
finally:
if tmp_path and tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
# 构建素材元信息
metadata_: dict[str, object] = {
"source": "tts_job",
"tts_job_id": job.id,
"format": job.format,
"sample_rate": job.sample_rate,
"voice_id": job.voice_id,
"voice_name": job.voice_model or "",
}
if job.metadata:
# 保留原始 job 的有用元信息
for key in ("speed", "language"):
if key in job.metadata:
metadata_[key] = job.metadata[key]
# 获取用户套餐(用于配额检查)
user = user_repository.find_by_id(user_id)
plan_name = getattr(user, "subscription_plan", "free") if user else "free"
# 构建命令并执行
command = CreateVoiceLibraryCommand(
user_id=user_id,
asset = Asset.create(
project_id=library.project_id,
library_id=library.id,
name=name,
text=job.input_text,
voice_provider="cosyvoice",
voice_id=job.voice_id,
voice_name=job.voice_model or "",
audio_url=job.output_audio_url,
duration=job.duration,
file_size=job.file_size,
status="completed",
project_id=job.project_id or "",
tags=[],
metadata_=metadata_,
storage_key=storage_key,
mime_type=content_type,
metadata=metadata_,
file_size=file_size,
duration=job.duration or None,
status=AssetStatus.READY,
classification_status=ClassificationStatus.PENDING, # 音频不参与内容分类,保持 pending 与 ingest 链路一致
uploaded_by_user_id=user_id,
)
use_case = CreateVoiceLibraryUseCase(voice_library_repository)
try:
item = use_case.execute(command, plan_name=plan_name or "free")
except QuotaExceededError as exc:
asset = asset_repository.create(asset)
except Exception as e:
# DB 写入失败:清理已上传到 OSS 的素材文件,避免产生无法索引的孤儿文件
logger.error("素材记录创建失败,清理 OSS 文件: %s, error=%s", storage_key, e, exc_info=True)
try:
storage_service.delete_file(storage_key)
except Exception:
logger.warning("清理孤儿 OSS 文件失败: %s", storage_key, exc_info=True)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc
status_code=status.HTTP_502_BAD_GATEWAY,
detail="素材保存失败,请重试",
) from e
return SaveToLibraryResponse(
id=item.id,
name=item.name,
audio_url=sign_url(item.audio_url) if item.audio_url else "",
duration=item.duration,
voice_id=item.voice_id,
voice_name=item.voice_name,
status=item.status,
id=asset.id,
name=asset.name,
audio_url=sign_url(storage_key),
duration=asset.duration or 0.0,
voice_id=job.voice_id,
voice_name=job.voice_model or "",
status="completed",
)
@router.post("/preview", response_model=TTSPreviewResponse)
def preview_tts(
request: TTSPreviewRequest,
+1
View File
@@ -52,6 +52,7 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
generation_params=item.generation_params,
download_url=download_url,
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
duplicate_rate=getattr(item, "duplicate_rate", None),
)
+58 -4
View File
@@ -7,7 +7,13 @@ from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_repository,
get_cosyvoice_service,
get_project_repository,
get_voice_clone_profile_repository,
)
from app.schemas.voice_clone import (
CreateVoiceCloneRequest,
ListVoiceCloneResponse,
@@ -32,6 +38,9 @@ from packages.application.voice_clone.use_cases import (
from packages.application.voice_clone.workflow import (
VoiceCloneWorkflowService,
)
from packages.ports.asset_repository import AssetRepository
from packages.ports.project_repository import ProjectRepository
from packages.shared.storage import SharedStorageService
logger = logging.getLogger(__name__)
@@ -83,23 +92,68 @@ def create_voice_clone(
request: CreateVoiceCloneRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
asset_repository: AssetRepository = Depends(get_asset_repository),
project_repository: ProjectRepository = Depends(get_project_repository),
storage_service: SharedStorageService = Depends(get_storage_service),
) -> VoiceCloneProfileResponse:
"""创建音色克隆任务。
创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。
如果有 source_audio_url,状态会变为 processing;否则保持 pending。
参考音频两种来源(二选一):
- source_audio_url:前端直传后的音频 URL(兼容旧流程)
- asset_id:配音素材库中的音频素材,服务端用其 OSS storage_key 生成
预签名下载 URL(不依赖前端签名,避免签名过期导致克隆失败)
如果有参考音频,状态会变为 processing;否则保持 pending。
"""
user_id = authenticated_user.user.id
source_audio_url = request.source_audio_url
clone_metadata = dict(request.metadata_ or {})
if request.asset_id:
if source_audio_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="asset_id 与 source_audio_url 只能传一个",
)
asset = asset_repository.find_by_id(request.asset_id)
if asset is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="素材不存在",
)
# 归属校验:素材挂在项目素材库下,用户必须能访问该项目
project = project_repository.find_by_id(asset.project_id)
if project is None or not project.can_access(user_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权使用该素材",
)
# 类型校验:仅支持音频素材
if asset.file_type != "audio":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="仅支持音频素材进行音色克隆",
)
if not asset.storage_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该素材缺少音频文件,无法用于克隆",
)
# 用 OSS storage_key 生成服务端预签名 URL(7 天有效,覆盖克隆重试周期)
source_audio_url = storage_service.get_download_url(asset.storage_key, expires_seconds=7 * 24 * 3600)
clone_metadata["source_asset_id"] = asset.id
profile = workflow.start_clone(
user_id=user_id,
name=request.name,
description=request.description,
source_audio_url=request.source_audio_url,
source_audio_url=source_audio_url,
voice_model=request.voice_model,
language=request.language,
gender=request.gender,
max_retries=request.max_retries,
metadata=request.metadata_,
metadata=clone_metadata,
)
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
+14 -3
View File
@@ -53,6 +53,14 @@ class AssetResponse(BaseModel):
created_at: str
uploaded_by_user_id: str
tag_ids: list[str] = Field(default_factory=list)
# 片段级余量信息(仅视频素材返回,非视频/无时长记录为 None,前端按可用处理)
used_duration: float | None = Field(default=None, description="已使用片段时长(秒,历史区间合并去重后)")
available_duration: float | None = Field(default=None, description="剩余可用时长(秒)= 素材总时长 - 已用时长")
used_ratio: float | None = Field(default=None, description="已用时长占比(0~1")
usable: bool = Field(
default=True,
description="是否仍可用于新片段:零重复可切区间耗尽且所有历史区间复用次数" "use_count)均达上限时为 false",
)
MAX_BATCH_SIZE = 200
@@ -121,10 +129,13 @@ class SmartMatchRequest(BaseModel):
)
class SmartMatchItem(BaseModel):
"""智能选素材结果条目"""
class SmartMatchItem(AssetResponse):
"""智能选素材结果条目(扁平结构)。
素材字段(id/usable/余量等)直接挂在条目顶层,前端拿到 item 即可读 item.id
与 AssetResponse 字段完全一致;score/breakdown 为智能匹配附加的评分字段。
"""
asset: AssetResponse
score: float = Field(..., ge=0, le=100, description="综合得分 0-100")
breakdown: dict[str, float] = Field(default_factory=dict, description="各维度得分明细")
+1
View File
@@ -22,6 +22,7 @@ class VideoItemResponse(BaseModel):
generation_params: dict = Field(default_factory=dict)
download_url: str | None = None
generated_at: str = ""
duplicate_rate: float | None = None
class ListVideosResponse(BaseModel):
+2 -1
View File
@@ -13,7 +13,8 @@ class CreateVoiceCloneRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="音色名称")
description: str = Field("", description="音色描述")
source_audio_url: str = Field("", description="参考音频 URL")
source_audio_url: str = Field("", description="参考音频 URL(与 asset_id 二选一)")
asset_id: str = Field("", description="参考音频素材 ID(配音素材库中的音频 asset,与 source_audio_url 二选一)")
voice_model: str = Field("", description="语音模型名称")
language: str = Field("zh-CN", description="语言")
gender: str = Field("unknown", description="性别")
@@ -0,0 +1,494 @@
"""素材片段级使用记录追踪与受控复用.
在素材 metadataassets.classification_result JSON)中持久化已使用的片段时间区间,
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 15%),把任意两条
成片的画面重复率控制在阈值内。
metadata 中的记录字段 ``used_time_ranges``::
"used_time_ranges": [
{
"start": 12.5, "end": 20.3,
"plan_id": "plan-xxx",
"created_at": "2026-08-29T12:00:00+00:00",
"use_count": 1, # 该区间累计被使用次数(复用一次 +1)
"last_used_at": "2026-08-29T12:00:00+00:00" # 最近一次使用时间
},
...
]
注意:本模块所有函数都不自行 commit,由调用方控制事务边界
from-assets 与 replace_all_clips_transactional 同事务;异步任务各自 commit)。
历史记录永不自动清空(自动轮回重置已下线,reset_used_segments 仅保留给运维/测试)。
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Callable
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetModel
logger = logging.getLogger(__name__)
USED_RANGES_KEY = "used_time_ranges"
# ── 受控复用配置常量 ─────────────────────────────────────────────────────────
MAX_RANGE_USE_COUNT = 3
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
REUSE_RATIO_LIMIT = 0.15
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(15%)。
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
SEGMENT_EDGE_GAP = 0.3
"""冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测,
避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。"""
# 判定"新片段与历史区间为同一次使用(复用)"的重叠率阈值:
# 重叠时长 / 新区间时长超过该比例视为复用该历史区间(累加 use_count)而非新增记录。
_REUSE_OVERLAP_RATIO = 0.6
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _read_meta(model) -> dict:
"""读取素材 metadata dict。
兼容两种对象:
- ORM ``AssetModel``metadata 以 JSON 字符串存在 ``classification_result`` 列;
- 领域实体 ``Asset``(路由层 repository 返回):metadata 直接是 dict 属性
repository 与 classification_result 互转,见 asset_repository.py)。
"""
# 领域实体:metadata 已是 dict
meta = getattr(model, "metadata", None)
if isinstance(meta, dict):
return meta
raw = getattr(model, "classification_result", None)
if not raw:
return {}
try:
data = json.loads(raw) if isinstance(raw, str) else raw
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _get_model(db: Session, asset_id: str, for_update: bool = False) -> AssetModel | None:
query = db.query(AssetModel).filter(AssetModel.id == asset_id)
if for_update:
# 行级锁(PostgreSQL SELECT ... FOR UPDATE):序列化同一素材的
# classification_result 读-改-写,避免并发事务丢失使用记录。
# SQLite 不支持时 SQLAlchemy 会忽略该子句(no-op)。
query = query.with_for_update()
return query.first()
def get_used_segments(db: Session, asset_ids: list[str]) -> dict[str, list[tuple[float, float]]]:
"""聚合多个素材的历史已用片段区间。
Returns:
``{asset_id: [(start, end), ...]}`` 格式,与 ``_calc_random_start_time`` 的
``used_segments`` 参数格式一致,可直接传入。
"""
if not asset_ids:
return {}
result: dict[str, list[tuple[float, float]]] = {}
models = db.query(AssetModel).filter(AssetModel.id.in_(list(set(asset_ids)))).all()
for model in models:
meta = _read_meta(model)
ranges = meta.get(USED_RANGES_KEY) or []
segments: list[tuple[float, float]] = []
for r in ranges:
try:
segments.append((float(r["start"]), float(r["end"])))
except (KeyError, TypeError, ValueError):
continue
if segments:
result[model.id] = segments
return result
def record_used_segments(
db: Session,
asset_id: str,
start: float,
end: float,
plan_id: str,
) -> None:
"""记录一次片段使用(不 commit.
若新区间与某条历史区间高度重叠(复用场景,如受控复用回调返回的区间、
MediaKit 挪到历史区间),则累加该记录的 ``use_count`` 并刷新 ``last_used_at``
不新增记录;否则追加一条新记录(use_count=1)。
"""
# 行级锁读取:与并发生成任务互斥,保证区间记录读-改-写一致
model = _get_model(db, asset_id, for_update=True)
if model is None:
logger.warning("[片段追踪] 素材不存在,跳过记录: asset_id=%s", asset_id)
return
meta = _read_meta(model)
ranges = list(meta.get(USED_RANGES_KEY) or [])
new_start = round(float(start), 3)
new_end = round(float(end), 3)
new_dur = max(new_end - new_start, 1e-6)
now = _now_iso()
for r in ranges:
try:
rs, re_ = float(r["start"]), float(r["end"])
except (KeyError, TypeError, ValueError):
continue
overlap = max(0.0, min(new_end, re_) - max(new_start, rs))
if overlap / new_dur >= _REUSE_OVERLAP_RATIO:
# 复用同一条历史区间:累加次数、刷新时间
r["use_count"] = int(r.get("use_count", 1)) + 1
r["last_used_at"] = now
r["plan_id"] = plan_id
meta[USED_RANGES_KEY] = ranges
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
return
ranges.append(
{
"start": new_start,
"end": new_end,
"plan_id": plan_id,
"created_at": now,
"use_count": 1,
"last_used_at": now,
}
)
meta[USED_RANGES_KEY] = ranges
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
def remove_used_segment(
db: Session,
asset_id: str,
start: float,
end: float,
plan_id: str | None = None,
tolerance: float = 0.5,
) -> bool:
"""删除素材 metadata 中匹配的一条使用记录(不 commit).
匹配规则:start/end 与记录值相差不超过 tolerance 秒;plan_id 非空时,
记录有 plan_id 则需相等,记录缺 plan_id(本功能上线前的旧数据)时按时间匹配。
Returns:
是否找到并删除了记录。
"""
model = _get_model(db, asset_id)
if model is None:
return False
meta = _read_meta(model)
ranges = list(meta.get(USED_RANGES_KEY) or [])
remaining: list[dict] = []
removed = False
for r in ranges:
try:
match = (
abs(float(r["start"]) - float(start)) <= tolerance and abs(float(r["end"]) - float(end)) <= tolerance
)
except (KeyError, TypeError, ValueError):
remaining.append(r)
continue
# plan_id 校验:传入 plan_id 时,记录有 plan_id 则必须相等;
# 记录本身缺 plan_id(旧数据)时退化为按时间匹配,避免旧区间永远删不掉
if plan_id is not None and r.get("plan_id") is not None and r.get("plan_id") != plan_id:
match = False
if match and not removed:
removed = True
continue
remaining.append(r)
if removed:
meta[USED_RANGES_KEY] = remaining
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
return removed
def reset_used_segments(db: Session, asset_id: str) -> None:
"""清空单个素材的历史片段使用记录(不 commit).
仅供运维/测试使用;正常生成流程中历史记录永不自动清空(受控复用取代自动轮回)。
"""
model = _get_model(db, asset_id)
if model is None:
return
meta = _read_meta(model)
if meta.get(USED_RANGES_KEY):
meta[USED_RANGES_KEY] = []
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
logger.info("[片段追踪] 素材区间记录手动清空: asset_id=%s", asset_id)
# ── 素材余量/可用性计算(Task H:素材库角标 + smart-match 过滤)──────────────
# 判定「是否还有空闲可切区间」时使用的最小片段时长(秒):空闲段长于此值才视为可切
_MIN_FREE_CLIP_DURATION = 3.0
def _merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""合并重叠/相接的时间区间,返回升序不重叠区间列表。"""
if not intervals:
return []
ordered = sorted((float(a), float(b)) for a, b in intervals if b > a)
merged: list[tuple[float, float]] = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
def _has_free_gap(used: list[tuple[float, float]], total: float, min_free: float = _MIN_FREE_CLIP_DURATION) -> bool:
"""素材 [0, total] 中是否存在长度 ≥ min_free 的空闲段(考虑边缘间隙)。"""
if total <= 0:
return False
# 历史区间按边缘间隙扩边后判定空闲(与选片冲突检测同一口径)
expanded = [(max(0.0, s - SEGMENT_EDGE_GAP), min(total, e + SEGMENT_EDGE_GAP)) for s, e in used]
merged = _merge_intervals(expanded)
cursor = 0.0
for start, end in merged:
if start - cursor >= min_free:
return True
cursor = max(cursor, end)
return total - cursor >= min_free
def compute_asset_availability(
model: "AssetModel | None",
min_free_clip_duration: float = _MIN_FREE_CLIP_DURATION,
) -> dict | None:
"""计算单个素材的余量与可用性(纯函数,不读写 DB)。
Returns:
视频素材返回 ``{"used_duration", "available_duration", "used_ratio", "usable"}``
非视频 / 无 model / 无时长信息返回 None(调用方按可用处理,零影响)。
usable=False 条件(与受控复用机制一致):
零重复可切区间已耗尽(不存在 ≥ min_free 的空闲段)且
所有历史区间 use_count 均达 MAX_RANGE_USE_COUNT 上限(无区间可复用)。
"""
if model is None:
return None
file_type = getattr(model, "file_type", None) or getattr(model, "mime_type", "") or ""
if file_type != "video" and not str(file_type).startswith("video/"):
return None
total = float(getattr(model, "duration", 0.0) or 0.0)
if total <= 0:
return None
meta = _read_meta(model)
raw_ranges = meta.get(USED_RANGES_KEY) or []
intervals: list[tuple[float, float]] = []
use_counts: list[int] = []
for r in raw_ranges:
try:
start = float(r["start"])
end = float(r["end"])
except (KeyError, TypeError, ValueError):
continue
if end <= start:
continue
intervals.append((start, end))
try:
use_counts.append(int(r.get("use_count", 1)))
except (TypeError, ValueError):
use_counts.append(1)
merged = _merge_intervals(intervals)
used_duration = round(sum(e - s for s, e in merged), 3)
used_duration = min(used_duration, total)
available_duration = round(max(total - used_duration, 0.0), 3)
used_ratio = round(min(used_duration / total, 1.0), 4)
has_free = _has_free_gap(intervals, total, min_free_clip_duration)
if has_free:
usable = True
else:
# 空闲段耗尽:仅当存在历史区间且全部达复用上限时才判定不可用;
# 无历史区间(理论上不会走到,因为 has_free=True)按可用处理
if not use_counts:
usable = True
else:
usable = any(uc < MAX_RANGE_USE_COUNT for uc in use_counts)
return {
"used_duration": used_duration,
"available_duration": available_duration,
"used_ratio": used_ratio,
"usable": usable,
}
def find_reusable_range(
db: Session,
asset_id: str,
clip_duration: float,
asset_total: float,
*,
max_use_count: int = MAX_RANGE_USE_COUNT,
) -> tuple[float, float] | None:
"""受控复用:在素材历史区间中选一条可复用区间返回 (start, end)。
选择规则:
1. 仅选 ``use_count < max_use_count`` 的历史区间;
2. 优先返回能完整容纳当前 clip_duration(起点后不越素材边界)的最久未用区间;
3. 没有能容纳的,则返回 last_used_at 最老(或缺失 last_used_at 的旧数据优先)
且 use_count 最低的区间起点(可能与其他历史区间重叠,属降级复用);
4. 无任何可复用区间(记录为空或全部达上限)返回 None。
本函数只读不写;复用次数的累加由后续 record_used_segments 完成。
"""
model = _get_model(db, asset_id)
if model is None:
return None
meta = _read_meta(model)
ranges = [r for r in (meta.get(USED_RANGES_KEY) or []) if int(r.get("use_count", 1)) < max_use_count]
if not ranges:
return None
def _last_used(r: dict) -> str:
return str(r.get("last_used_at") or r.get("created_at") or "")
max_start = max(0.0, asset_total - clip_duration)
# 2. 能完整容纳当前片段的候选:按 last_used_at 升序(最久未用优先)
fit = sorted(
[r for r in ranges if float(r["start"]) <= max_start + 1e-6],
key=_last_used,
)
if fit:
start = min(float(fit[0]["start"]), max_start)
return (start, start + clip_duration)
# 3. 降级:最久未用 + use_count 最低的区间起点
fallback = sorted(ranges, key=lambda r: (_last_used(r), int(r.get("use_count", 1))))[0]
start = min(float(fallback["start"]), max_start)
return (start, start + clip_duration)
def make_reuse_callback(
db: Session,
asset_durations: dict[str, float],
reused_tracker: dict[str, float] | None = None,
assigned_tracker: dict[str, float] | None = None,
ratio_limit: float = REUSE_RATIO_LIMIT,
) -> Callable[[str, float], tuple[float, float] | None]:
"""构造给 ``_calc_random_start_time`` 用的受控复用回调.
Args:
db: SQLAlchemy session
asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束)
reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时
会把本次片段时长累加进去,供调用方统计成片复用占比(15% 阈值)。
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
在复用前预判:若复用本片段后占比 (reused + clip_duration) /
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
(保证成片复用占比不超阈值)。
ratio_limit: 单条成片复用时长占比上限,默认 15%
Returns:
回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。
回调内吞掉 DB 异常返回 None,不影响主生成流程。
"""
def _reuse(asset_id: str, clip_duration: float) -> tuple[float, float] | None:
try:
total = float(asset_durations.get(asset_id, 0.0) or 0.0)
if total <= 0:
return None
# 占比闸门:预判复用本片段后是否超限(仅当调用方提供了 assigned tracker
if assigned_tracker is not None:
assigned = float(assigned_tracker.get(asset_id, 0.0) or 0.0)
reused_amt = float((reused_tracker or {}).get(asset_id, 0.0) or 0.0)
if assigned > 0 and (reused_amt + clip_duration) / (assigned + clip_duration) > ratio_limit:
logger.info(
"[片段追踪] 复用占比预判超 %.0f%% 阈值,拒绝复用: asset_id=%s "
"reused=%.1f assigned=%.1f clip=%.1f",
ratio_limit * 100,
asset_id,
reused_amt,
assigned,
clip_duration,
)
return None
result = find_reusable_range(db, asset_id, clip_duration, total)
except Exception:
logger.warning("[片段追踪] 受控复用查询异常: asset_id=%s", asset_id, exc_info=True)
return None
if result is not None and reused_tracker is not None:
reused_tracker[asset_id] = reused_tracker.get(asset_id, 0.0) + clip_duration
return result
return _reuse
def get_asset_recent_use_counts(
db: Session,
asset_ids: list[str],
recent_video_count: int = 5,
) -> dict[str, int]:
"""统计每个素材在最近 N 个不同 plan_id 中的使用次数。
遍历素材 metadata 中的 used_time_ranges,统计有多少个不同的 plan_id(去重),
返回 {asset_id: count}。只统计最近 recent_video_count 个不同 plan_id 的使用次数。
Args:
db: 数据库会话
asset_ids: 素材 ID 列表
recent_video_count: 统计最近多少个不同 plan_id
Returns:
{asset_id: 在最近 recent_video_count 个 plan 中的使用次数}
"""
if not asset_ids:
return {}
result: dict[str, int] = {}
models = db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
for model in models:
meta = _read_meta(model)
ranges = meta.get(USED_RANGES_KEY) or []
if not ranges:
result[model.id] = 0
continue
# 按 created_at 倒序收集不同 plan_id
sorted_ranges = sorted(
ranges,
key=lambda r: r.get("created_at") or "",
reverse=True,
)
recent_plan_ids: set[str] = set()
for r in sorted_ranges:
plan_id = r.get("plan_id")
if plan_id:
recent_plan_ids.add(plan_id)
if len(recent_plan_ids) >= recent_video_count:
break
result[model.id] = len(recent_plan_ids)
# 未找到的素材计为 0
for aid in asset_ids:
if aid not in result:
result[aid] = 0
return result
+116
View File
@@ -9,6 +9,12 @@ from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
get_used_segments,
make_reuse_callback,
record_used_segments,
)
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import (
@@ -453,6 +459,116 @@ class EditPlanService:
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
raise
def clone_plan_for_variant(
self,
source_plan_id: str,
*,
created_by_user_id: str = "",
name_suffix: str = "变体",
reuse_tracker: Optional[dict] = None,
) -> EditPlan:
"""为批量/多预览场景克隆一份独立 plan,片段起点全部重算(受控随机/复用)。
复制源 plan 的模板归属、config 与片段结构(asset_id / duration / clip_type /
order 不变),每个片段重新调用 ``_calc_random_start_time``:读取素材持久化的
历史已用区间避让,耗尽时受控复用(use_count<3、最久未用),从而保证 N 条
成片片段区间互不相同,且复用占比受控。
- 不替换/不修改源 plan,源 plan 保留用户手动编辑结果。
- 片段区间记录(record_used_segments)随新片段写入素材 metadata,与新 plan
同事务;复用历史区间时由 record 自动累加 use_count。
- 克隆的 clips 复用区间累计时长写入 reuse_tracker(可选),供调用方统计占比。
Raises:
ValueError: 源 plan 不存在或无可用片段。
"""
from packages.adapters.sqlalchemy_impl.models import AssetModel
from packages.domain.plan_generator_utils import _calc_random_start_time
source = self.get_plan_or_raise(source_plan_id)
# 分页读取源 plan 全部片段
clips: List[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
if not batch:
break
clips.extend(batch)
if len(batch) < page:
break
skip += page
if not clips:
raise ValueError(f"源 plan 无片段,无法克隆变体: {source_plan_id}")
# 创建新 plan(复制模板归属与 config
new_plan = self.create_plan(
template_id=source.template_id,
name=f"{source.name or '剪辑计划'} · {name_suffix}",
config=dict(source.config or {}),
total_duration=source.total_duration,
project_id=source.project_id or "",
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
)
# 素材时长映射(O(N) 单查)
asset_ids = list({c.asset_id for c in clips if c.asset_id})
db = self._clip_repo.session
durations: dict[str, float] = {}
if asset_ids:
for m in db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all():
durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
used_segments = get_used_segments(db, asset_ids)
reused: dict[str, float] = reuse_tracker if reuse_tracker is not None else {}
asset_assigned: dict[str, float] = {}
# 回调内部预判复用后占比超 15% 则拒绝复用(calc 返回 None → 保留原起点)
reuse_cb = make_reuse_callback(db, durations, reused, assigned_tracker=asset_assigned)
clips_data: list[dict] = []
for i, c in enumerate(clips):
aid = c.asset_id
dur = float(c.duration or 0.0)
total = durations.get(aid, 0.0)
if aid and total > 0 and dur > 0:
# 复用占比闸门:本片段尚未分配,检查当前已复用占比
# reused / assigned 是否超 15%,超则该素材不再分配(保留原起点);
# assigned=0(首个片段)放行,reused=0 时不误拦正常分配
assigned = asset_assigned.get(aid, 0.0)
eff_dur = min(dur, total)
reused_amt = reused.get(aid, 0.0)
ratio_blocked = assigned > 0 and reused_amt / assigned > REUSE_RATIO_LIMIT
start = None
if not ratio_blocked:
start = _calc_random_start_time(aid, eff_dur, durations, used_segments, on_exhausted=reuse_cb)
if start is None:
start = float(c.start_time or 0.0)
asset_assigned[aid] = assigned + eff_dur
used_segments.setdefault(aid, []).append((start, start + eff_dur))
record_used_segments(db, aid, start, start + eff_dur, new_plan.id)
else:
start = float(c.start_time or 0.0)
clips_data.append(
{
"order": c.order if c.order is not None else i,
"asset_id": aid,
"start_time": start,
"duration": dur,
"clip_type": c.clip_type,
}
)
# 事务性写入新 plan 的片段(内部统一 commit/rollback
self.replace_all_clips_transactional(new_plan.id, clips_data)
logger.info(
"克隆变体 plan: source=%s new=%s clips=%d",
source_plan_id,
new_plan.id,
len(clips_data),
)
return new_plan
# ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
+33 -5
View File
@@ -52,12 +52,40 @@ export const getAssetsByKind = async (
/**
* 智能匹配素材(后端 AI 选素材)
* 调用后端 smart-match 端点,由后端根据素材库内容智能选择素材
*
* 后端返回 items 元素兼容两种结构(过渡期):
* - 扁平结构:AssetItem 本身(id 在顶层)
* - 包装结构:{ asset: AssetItem, score, breakdown }id 需从 .asset 取)
* 这里统一归一化为 AssetItem[],调用方无需关心包装层。
*/
export const smartMatchAssets = async (libraryId: string): Promise<{ items: AssetItem[] }> => {
const response = await apiClient.post("/assets/smart-match", {
library_id: libraryId,
})
return response.data
export interface SmartMatchResult {
items: AssetItem[]
}
interface SmartMatchWrappedItem {
asset?: AssetItem
id?: string
score?: number
breakdown?: unknown
}
export const smartMatchAssets = async (
libraryId: string,
limit?: number,
): Promise<SmartMatchResult> => {
const payload: Record<string, unknown> = { library_id: libraryId }
if (limit && limit > 0) payload.limit = limit
const response = await apiClient.post("/assets/smart-match", payload)
const rawItems: SmartMatchWrappedItem[] = response.data?.items ?? []
const items = rawItems
.map((it) =>
// 包装结构 { asset: {...} } 优先解包;否则视其本身为扁平 AssetItem
it?.asset && typeof it.asset === "object" && "id" in it.asset
? it.asset
: (it as unknown as AssetItem),
)
.filter((it): it is AssetItem => !!it && typeof it.id === "string" && it.id.length > 0)
return { items }
}
/** 更新素材(名称、metadata 等) */
+7 -2
View File
@@ -2,12 +2,17 @@
* 素材诊断 API
*/
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { AssetDiagnosis } from "./types"
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
export const getAssetDiagnosis = async (
assetId?: string,
projectId?: string,
): Promise<AssetDiagnosis> => {
const pid = projectId ?? (await getOrCreateDefaultProject()).id
const params: Record<string, string> = {}
if (assetId) params.asset_id = assetId
const response = await apiClient.get("/asset-diagnosis", { params })
const response = await apiClient.get(`/projects/${pid}/asset-diagnosis`, { params })
return response.data
}
+11 -1
View File
@@ -20,6 +20,10 @@ export type {
// 素材诊断
export { getAssetDiagnosis } from "./diagnosis"
// 素材余量/可用性判断
export { isAssetUsable } from "./usage"
export type { AssetUsageLike } from "./usage"
// 素材库
export {
getAssetLibraries,
@@ -39,7 +43,13 @@ export {
} from "./assets"
// 上传
export { prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
export {
prepareDirectUpload,
completeDirectUpload,
uploadAssetDirect,
prepareDirectUploadHandle,
type DirectUploadHandle,
} from "./upload"
// 任务
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
+14
View File
@@ -40,6 +40,10 @@ export interface AssetItem {
thumbnail_url?: string
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
duration?: number
/** 已切片段占用时长占比(0~1,后端片段重复率控制机制返回;字段缺失视为未统计) */
used_ratio?: number | null
/** 是否已彻底用尽(无新区间且历史区间复用次数均达上限);false 的素材不参与生成选片 */
usable?: boolean | null
status?: string
classification_status?: AssetClassificationStatus | null
quality_score?: number | null
@@ -129,6 +133,12 @@ export interface DirectUploadPrepareResult {
expires_at: string
fields: Record<string, string>
max_size_bytes: number
/**
* prepare 阶段预创建的素材记录 id(后端改造后返回:status=uploading)。
* 前端拿到后立即刷新列表,卡片以「上传中」态出现在素材网格中。
* 旧后端不返回该字段,前端降级为无预建卡片的原有行为。
*/
asset_id?: string
}
/** 直传完成确认返回 */
@@ -136,4 +146,8 @@ export interface DirectUploadCompleteResult {
storage_key: string
ingest_job_id: string
url: string
/** 同库已存在相同 file_hash 的素材时为 trueingest_job_id 为空 */
duplicated?: boolean
/** duplicated 为 true 时返回已存在素材的 id */
asset_id?: string
}
+59 -25
View File
@@ -27,28 +27,18 @@ export const completeDirectUpload = async (data: {
return response.data
}
/** 直传上传(大文件推荐),支持可选进度回调 */
export const uploadAssetDirect = async (data: {
file: File
library_id: string
onProgress?: (percent: number) => void
}): Promise<DirectUploadCompleteResult> => {
const project = await getOrCreateDefaultProject()
/** 直传 OSS 的底层传输(POST 表单到 OSS),带进度回调 */
const putToOSS = (
prepared: DirectUploadPrepareResult,
file: File,
onProgress?: (percent: number) => void,
): Promise<void> =>
new Promise<void>((resolve, reject) => {
const directForm = new FormData()
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
directForm.append("file", file)
const prepared = await prepareDirectUpload({
project_id: project.id,
library_id: data.library_id,
filename: data.file.name,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
})
const directForm = new FormData()
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
directForm.append("file", data.file)
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
await new Promise<void>((resolve, reject) => {
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
const xhr = new XMLHttpRequest()
xhr.open(prepared.method, prepared.upload_url)
@@ -56,8 +46,8 @@ export const uploadAssetDirect = async (data: {
xhr.timeout = 10 * 60 * 1000
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && data.onProgress) {
data.onProgress(Math.round((e.loaded / e.total) * 100))
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
}
xhr.onload = () => {
@@ -102,9 +92,53 @@ export const uploadAssetDirect = async (data: {
xhr.send(directForm)
})
return completeDirectUpload({
/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */
export interface DirectUploadHandle {
/** prepare 返回(含可能的预建 asset_id */
prepared: DirectUploadPrepareResult
/** 直传 OSS(可重复调用用于重试) */
transfer: (onProgress?: (percent: number) => void) => Promise<void>
/** 直传完成后调用 complete 确认入库 */
complete: () => Promise<DirectUploadCompleteResult>
}
/**
* 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset),
* 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。
*/
export const prepareDirectUploadHandle = async (data: {
file: File
library_id: string
}): Promise<DirectUploadHandle> => {
const project = await getOrCreateDefaultProject()
const prepared = await prepareDirectUpload({
project_id: project.id,
library_id: data.library_id,
storage_key: prepared.storage_key,
filename: data.file.name,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
})
return {
prepared,
transfer: (onProgress) => putToOSS(prepared, data.file, onProgress),
complete: () =>
completeDirectUpload({
project_id: project.id,
library_id: data.library_id,
storage_key: prepared.storage_key,
}),
}
}
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */
export const uploadAssetDirect = async (data: {
file: File
library_id: string
onProgress?: (percent: number) => void
}): Promise<DirectUploadCompleteResult> => {
const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id })
await handle.transfer(data.onProgress)
return handle.complete()
}
+18
View File
@@ -0,0 +1,18 @@
/**
* 素材余量/可用性判断
* 后端片段重复率控制机制(任意两条成片画面重复率 ≤15%)上线后,
* 素材列表会附加 usable / used_ratio 字段。字段未上线前一律按可用处理。
*/
/** 仅依赖素材余量相关字段的最小结构,api 层与 pages 层 AssetItem 均可传入 */
export interface AssetUsageLike {
usable?: boolean | null
used_ratio?: number | null
}
/**
* 素材是否仍可参与生成选片。
* usable === false 表示已彻底用尽(无新区间且复用次数全部达上限);
* 字段缺失(undefined/null)时降级为可用,保证后端字段上线前零影响。
*/
export const isAssetUsable = (asset: AssetUsageLike): boolean => asset.usable !== false
+7 -2
View File
@@ -44,14 +44,19 @@ export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile
return response.data
}
/** 创建克隆音色 */
/** 创建克隆音色audio_url 与 asset_id 二选一) */
export const createVoiceClone = async (
data: CreateVoiceCloneRequest,
): Promise<VoiceCloneProfile> => {
const payload: CreateVoiceCloneRequestFull = {
name: data.name,
description: data.description,
source_audio_url: data.audio_url,
}
// 从配音素材选择克隆:直接传 asset_id,后端用素材 OSS 路径克隆
if (data.asset_id) {
payload.asset_id = data.asset_id
} else {
payload.source_audio_url = data.audio_url
}
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
return response.data
+9 -4
View File
@@ -22,10 +22,13 @@ export interface VoiceClone {
updated_at: string
}
/** 创建克隆请求(前端简化版) */
/** 创建克隆请求(前端简化版audio_url 与 asset_id 二选一 */
export interface CreateVoiceCloneRequest {
name: string
audio_url: string
/** 录音/文件上传后的音频 URL(与 asset_id 二选一) */
audio_url?: string
/** 从配音素材选择时直接传素材 ID,后端用素材 OSS 路径克隆(与 audio_url 二选一) */
asset_id?: string
description?: string
}
@@ -72,11 +75,13 @@ export interface VoiceCloneStatusResponse {
retry_count: number
}
/** 后端创建克隆请求(完整版) */
/** 后端创建克隆请求(完整版source_audio_url 与 asset_id 二选一 */
export interface CreateVoiceCloneRequestFull {
name: string
description?: string
source_audio_url: string
source_audio_url?: string
/** 从配音素材选择克隆时传素材 ID */
asset_id?: string
voice_model?: string
language?: string
gender?: string
@@ -149,53 +149,29 @@
/* ── 上传区域 ───────────────────────────────────────────── */
.xx-clonemodal-upload-zone {
border: 2px dashed var(--xx-color-border, #e5e7eb);
/* ── 素材选择空态 ─────────────────────────────────────────── */
.xx-clonemodal-asset-empty {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px dashed var(--xx-color-border, #e5e7eb);
border-radius: var(--radius-sm);
padding: 28px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--xx-color-bg-secondary, #f9fafb);
}
.xx-clonemodal-upload-zone:hover {
border-color: var(--xx-color-primary, #6366f1);
background: rgba(99, 102, 241, 0.03);
}
.xx-clonemodal-upload-zone--active {
border-color: var(--xx-color-primary, #6366f1);
background: rgba(99, 102, 241, 0.06);
}
.xx-clonemodal-upload-zone--has-file {
border-style: solid;
border-color: var(--xx-color-primary, #6366f1);
background: rgba(99, 102, 241, 0.04);
}
.xx-clonemodal-upload-icon {
font-size: 32px;
margin-bottom: 8px;
}
.xx-clonemodal-upload-title {
font-size: 14px;
font-weight: 500;
color: var(--xx-color-text, #111827);
margin: 0 0 4px;
word-break: break-all;
}
.xx-clonemodal-upload-hint {
font-size: 12px;
color: var(--xx-color-text-secondary, #6b7280);
.xx-clonemodal-asset-empty-text {
margin: 0;
font-size: 13px;
color: var(--xx-color-text-secondary, #6b7280);
}
/* ── 错误提示 ───────────────────────────────────────────── */
/* ── 错误提示 ───────────────────────────────────────────── */
.xx-clonemodal-error {
display: flex;
align-items: center;
@@ -7,14 +7,5 @@ export const PROGRESS_STEPS: ProgressStep[] = [
{ key: "done", label: "完成", icon: "✅" },
]
/** 支持的音频扩展名 */
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "webm"]
/** 文件选择器 accept 属性 */
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm"
/** 最大文件大小:10MB */
export const MAX_FILE_SIZE = 10 * 1024 * 1024
/** 最长录制时长:5 分钟(秒) */
export const MAX_RECORD_SECONDS = 5 * 60
+110 -103
View File
@@ -1,10 +1,12 @@
import React, { useState, useCallback, useRef, useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { Modal, Button } from "@/components/ui"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
import { uploadAssetDirect, ensureDefaultLibrary, getAssetsByKind } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
import { validateFile } from "./utils"
import { PROGRESS_STEPS } from "./constants"
import { formatRecordTime } from "./utils"
import { useAudioRecorder } from "./hooks/useAudioRecorder"
import type { CloneModalProps, ModalPhase } from "./types"
import "./clone-modal.css"
@@ -18,15 +20,21 @@ const getExtensionFromMime = (mime: string): string => {
return "webm"
}
/** 格式化素材时长(秒 → mm:ss) */
const formatAssetDuration = (seconds?: number): string => {
if (!seconds || seconds <= 0) return "--:--"
return formatRecordTime(Math.round(seconds))
}
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
const navigate = useNavigate()
const [phase, setPhase] = useState<ModalPhase>("input")
const [voiceName, setVoiceName] = useState("")
const [voiceDescription, setVoiceDescription] = useState("")
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
/** 从配音素材选择的素材 ID */
const [selectedAssetId, setSelectedAssetId] = useState<string>("")
const [errorMessage, setErrorMessage] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
const cloneCounterRef = useRef(1)
@@ -34,6 +42,14 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
const isMountedRef = useRef(true)
const isSubmittingRef = useRef(false)
/* ── 配音素材列表(「从配音素材选择」;弹窗打开时才发请求) ────── */
const { data: voiceAssets, isLoading: assetsLoading } = useQuery({
queryKey: ["assets", "voice", "clone-modal"],
queryFn: () => getAssetsByKind("voice", { limit: 100 }),
enabled: open,
staleTime: 30_000,
})
/* ── 录音 Hook ──────────────────────────────────── */
const {
isRecording,
@@ -55,8 +71,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
setPhase("input")
setVoiceName(getNextDefaultName())
setVoiceDescription("")
setSelectedFile(null)
setDragActive(false)
setSelectedAssetId("")
// 注意:resetState 不得触碰 isSubmittingRef——提交锁仅属于 handleSubmit
// 此前在此上锁且无复位路径,弹窗打开即死锁
setErrorMessage("")
@@ -85,65 +100,26 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
}
}, [])
/* ── 文件上传 ──────────────────────────────────── */
/* ── 素材/录音互斥:选择素材时清掉录音,开始录音时清掉素材选择 ── */
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const error = validateFile(file)
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
// 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作,
// 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁
setErrorMessage("")
setSelectedFile(file)
resetRecorder()
}
}
e.target.value = ""
}
/* ── 拖拽 ──────────────────────────────────────── */
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true)
} else if (e.type === "dragleave") {
setDragActive(false)
const handleSelectAsset = (assetId: string) => {
setSelectedAssetId(assetId)
if (assetId) {
resetRecorder()
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
const file = e.dataTransfer.files?.[0]
if (file) {
const error = validateFile(file)
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
// 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作,
// 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁
setErrorMessage("")
setSelectedFile(file)
resetRecorder()
}
const handleToggleRecord = () => {
// 开始录音会清掉已选素材;停止录音保留录音结果
if (!isRecording) {
setSelectedAssetId("")
}
toggleRecord()
}
/* ── 计算属性 ──────────────────────────────────── */
const hasAudio = selectedFile !== null || recordedBlob !== null
const hasAudio = selectedAssetId !== "" || recordedBlob !== null
const isProcessing = phase === "uploading" || phase === "cloning"
const canSubmit = hasAudio && !isProcessing
@@ -158,7 +134,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
return
}
if (!hasAudio) {
setErrorMessage("请上传音频文件或录制一段声音")
setErrorMessage("请从配音素材选择一段音频,或直接录制声音")
return
}
@@ -167,20 +143,37 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
setErrorMessage("")
try {
// 阶段 1:上传音频
// 路径 A:从配音素材选择 → 无需上传,直接克隆
if (selectedAssetId) {
setPhase("cloning")
const result = await createVoiceClone({
name,
description: voiceDescription.trim() || undefined,
asset_id: selectedAssetId,
})
if (!isMountedRef.current) return
isSubmittingRef.current = false
setPhase("done")
timerRef.current = setTimeout(() => {
if (isMountedRef.current) {
onSuccess?.(toVoiceClone(result))
handleClose()
}
}, 2000)
return
}
// 路径 B:录音 → 先上传为配音素材,再克隆
setPhase("uploading")
let fileToUpload: File
if (selectedFile) {
fileToUpload = selectedFile
} else {
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
const mimeType = recordedBlob?.type || "audio/webm"
const ext = getExtensionFromMime(mimeType)
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
type: mimeType,
})
}
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
const mimeType = recordedBlob?.type || "audio/webm"
const ext = getExtensionFromMime(mimeType)
const fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
type: mimeType,
})
// 获取默认项目和素材库
const project = await getOrCreateDefaultProject()
@@ -226,6 +219,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
}
}
const hasAssets = (voiceAssets?.length ?? 0) > 0
return (
<Modal
open={open}
@@ -244,7 +239,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
<div className="xx-clonemodal-steps">
<div className="xx-clonemodal-step xx-clonemodal-step--active">
<div className="xx-clonemodal-step-number">1</div>
<span className="xx-clonemodal-step-label">/</span>
<span className="xx-clonemodal-step-label">/</span>
</div>
<div className="xx-clonemodal-step-connector" />
<div className="xx-clonemodal-step">
@@ -274,30 +269,42 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
</div>
{/* 上传区域 */}
{/* 从配音素材选择 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<div
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
<p className="xx-clonemodal-upload-title">
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-clonemodal-upload-hint"> MP3WAVM4A 10MB</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME}
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
<label className="xx-clonemodal-label"></label>
{hasAssets ? (
<select
className="xx-clonemodal-input"
value={selectedAssetId}
onChange={(e) => handleSelectAsset(e.target.value)}
disabled={assetsLoading}
>
<option value="">{assetsLoading ? "素材加载中…" : "请选择已上传的配音素材"}</option>
{voiceAssets!.map((asset) => (
<option key={asset.id} value={asset.id}>
{asset.name}{formatAssetDuration(asset.duration)}
</option>
))}
</select>
) : (
<div className="xx-clonemodal-asset-empty">
<p className="xx-clonemodal-asset-empty-text">
{assetsLoading ? "素材加载中…" : "请先在配音库上传素材"}
</p>
{!assetsLoading && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
handleClose()
navigate("/app/voice-materials")
}}
>
</Button>
)}
</div>
)}
</div>
{/* 或分隔 */}
@@ -332,7 +339,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
<button
type="button"
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
onClick={toggleRecord}
onClick={handleToggleRecord}
title={isRecording ? "停止录制" : "开始录制"}
>
{isRecording ? "⏹" : "🎙️"}
@@ -365,7 +372,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
{/* 提示 */}
<div className="xx-clonemodal-tip">
<span className="xx-clonemodal-tip-icon">💡</span>
<span> 10 ~ 3 </span>
<span>使 10 ~ 3 </span>
</div>
{/* 底部按钮 */}
@@ -415,18 +422,18 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
{/* 当前阶段描述 */}
<div className="xx-clonemodal-progress-info">
{phase === "uploading" && (
<>
<div>
<div className="xx-clonemodal-progress-spinner" />
<p className="xx-clonemodal-progress-text"></p>
<p className="xx-clonemodal-progress-sub"></p>
</>
<p className="xx-clonemodal-progress-text"></p>
<p className="xx-clonemodal-progress-sub"></p>
</div>
)}
{phase === "cloning" && (
<>
<div>
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
<p className="xx-clonemodal-progress-text">AI </p>
<p className="xx-clonemodal-progress-sub"></p>
</>
</div>
)}
</div>
</div>
@@ -1,20 +1,3 @@
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "./constants"
/**
* 验证音频文件
* @returns 错误信息,null 表示验证通过
*/
export const validateFile = (file: File): string | null => {
const ext = file.name.split(".").pop()?.toLowerCase()
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
}
if (file.size > MAX_FILE_SIZE) {
return "文件大小超过 10MB,请压缩后重试"
}
return null
}
/** 格式化录制时间 mm:ss */
export const formatRecordTime = (seconds: number): string => {
const m = Math.floor(seconds / 60)
@@ -1,142 +0,0 @@
import React from "react"
import { Button } from "@/components/ui"
import UploadZone from "./UploadZone"
import RecordArea from "./RecordArea"
import StepIndicator from "./StepIndicator"
import { MAX_VOICE_NAME_LENGTH, MAX_VOICE_DESC_LENGTH } from "../constants/cloneModal"
interface InputViewProps {
voiceName: string
voiceDescription: string
selectedFile: File | null
dragActive: boolean
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
errorMessage: string
canSubmit: boolean
onVoiceNameChange: (value: string) => void
onVoiceDescChange: (value: string) => void
onDragActiveChange: (active: boolean) => void
onFileSelect: (file: File | null, error: string) => void
onRecordToggle: () => void
onClose: () => void
onSubmit: () => void
}
const INPUT_STEPS = ["上传/录制音频", "填写信息", "提交克隆"]
const InputView: React.FC<InputViewProps> = ({
voiceName,
voiceDescription,
selectedFile,
dragActive,
isRecording,
recordTime,
recordedBlob,
errorMessage,
canSubmit,
onVoiceNameChange,
onVoiceDescChange,
onDragActiveChange,
onFileSelect,
onRecordToggle,
onClose,
onSubmit,
}) => {
return (
<div className="xx-clonemodal-body">
{/* 步骤引导 */}
<StepIndicator currentStep={0} steps={INPUT_STEPS} />
{/* 音色名称 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label">
<span className="xx-clonemodal-required">*</span>
</label>
<input
type="text"
className="xx-clonemodal-input"
value={voiceName}
onChange={(e) => onVoiceNameChange(e.target.value)}
placeholder="输入音色名称(2-20字符)"
maxLength={MAX_VOICE_NAME_LENGTH}
/>
<div className="xx-clonemodal-char-count">
{voiceName.length}/{MAX_VOICE_NAME_LENGTH}
</div>
</div>
{/* 上传区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<UploadZone
selectedFile={selectedFile}
dragActive={dragActive}
onDragActiveChange={onDragActiveChange}
onFileSelect={onFileSelect}
/>
</div>
{/* 或分隔 */}
<div className="xx-clonemodal-divider">
<div className="xx-clonemodal-divider-line" />
<span className="xx-clonemodal-divider-text"></span>
<div className="xx-clonemodal-divider-line" />
</div>
{/* 录制区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<RecordArea
isRecording={isRecording}
recordTime={recordTime}
recordedBlob={recordedBlob}
onRecordToggle={onRecordToggle}
/>
</div>
{/* 音色描述 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<textarea
className="xx-clonemodal-textarea"
value={voiceDescription}
onChange={(e) => onVoiceDescChange(e.target.value)}
placeholder="可选,描述这个音色的特点(最多100字符)"
maxLength={MAX_VOICE_DESC_LENGTH}
rows={3}
/>
<div className="xx-clonemodal-char-count">
{voiceDescription.length}/{MAX_VOICE_DESC_LENGTH}
</div>
</div>
{/* 错误提示 */}
{errorMessage && (
<div className="xx-clonemodal-error">
<span className="xx-clonemodal-error-icon"></span>
<span>{errorMessage}</span>
</div>
)}
{/* 提示 */}
<div className="xx-clonemodal-tip">
<span className="xx-clonemodal-tip-icon">💡</span>
<span> 10 ~ 3 </span>
</div>
{/* 底部按钮 */}
<div className="xx-clonemodal-footer">
<Button buttonType="ghost" onClick={onClose}>
</Button>
<Button buttonType="primary" disabled={!canSubmit} onClick={onSubmit}>
🎤
</Button>
</div>
</div>
)
}
export default InputView
@@ -1,92 +0,0 @@
import React from "react"
import { PROGRESS_STEPS } from "../constants/cloneModal"
import type { ProgressStep } from "../types/cloneModal"
import type { ModalPhase } from "../types/cloneModal"
interface ProgressViewProps {
phase: ModalPhase
}
const getProgressIndex = (phase: ModalPhase): number => {
switch (phase) {
case "uploading":
return 0
case "cloning":
return 1
case "done":
return 2
default:
return -1
}
}
const ProgressView: React.FC<ProgressViewProps> = ({ phase }) => {
const progressIndex = getProgressIndex(phase)
const isDone = phase === "done"
return (
<div className="xx-clonemodal-progress-body">
{/* 步骤指示器 */}
<div className="xx-clonemodal-steps-progress">
{PROGRESS_STEPS.map((step: ProgressStep, idx: number) => {
const isActive = idx === progressIndex && !isDone
const stepDone = idx < progressIndex || isDone
const stepClass = [
"xx-clonemodal-step-progress",
isActive ? "xx-clonemodal-step-progress--active" : "",
stepDone ? "xx-clonemodal-step-progress--done" : "",
]
.filter(Boolean)
.join(" ")
return (
<React.Fragment key={step.key}>
{idx > 0 && (
<div
className={`xx-clonemodal-step-connector${stepDone ? " xx-clonemodal-step-connector--done" : ""}`}
/>
)}
<div className={stepClass}>
<div className="xx-clonemodal-step-icon">{stepDone ? "✓" : step.icon}</div>
<span className="xx-clonemodal-step-label">{step.label}</span>
</div>
</React.Fragment>
)
})}
</div>
{/* 完成阶段 */}
{isDone && (
<div className="xx-clonemodal-success">
<div className="xx-clonemodal-success-icon">🎉</div>
<h3 className="xx-clonemodal-success-title"></h3>
<p className="xx-clonemodal-success-desc">
</p>
</div>
)}
{/* 进行中阶段 */}
{!isDone && (
<div className="xx-clonemodal-progress-info">
{phase === "uploading" && (
<>
<div className="xx-clonemodal-progress-spinner" />
<p className="xx-clonemodal-progress-text"></p>
<p className="xx-clonemodal-progress-sub"></p>
</>
)}
{phase === "cloning" && (
<>
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
<p className="xx-clonemodal-progress-text">AI </p>
<p className="xx-clonemodal-progress-sub"></p>
</>
)}
</div>
)}
</div>
)
}
export default ProgressView
@@ -1,49 +0,0 @@
import React from "react"
import { formatRecordTime } from "../utils/cloneModal"
interface RecordAreaProps {
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
onRecordToggle: () => void
}
const RecordArea: React.FC<RecordAreaProps> = ({
isRecording,
recordTime,
recordedBlob,
onRecordToggle,
}) => {
const getHintText = () => {
if (isRecording) return `录制中 ${formatRecordTime(recordTime)}`
if (recordedBlob) return `已录制 ${formatRecordTime(recordTime)}`
return "点击按钮开始录制(最长 5 分钟)"
}
return (
<div className="xx-clonemodal-record-area">
<div className="xx-clonemodal-record-info">
<p className="xx-clonemodal-record-hint">{getHintText()}</p>
{isRecording && (
<div className="xx-clonemodal-record-wave">
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
</div>
)}
</div>
<button
type="button"
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
onClick={onRecordToggle}
title={isRecording ? "停止录制" : "开始录制"}
>
{isRecording ? "⏹" : "🎙️"}
</button>
</div>
)
}
export default RecordArea
@@ -1,30 +0,0 @@
import React from "react"
interface StepIndicatorProps {
currentStep: number
steps: string[]
}
/**
* 输入阶段顶部的步骤引导(数字步骤)
*/
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, steps }) => {
return (
<div className="xx-clonemodal-steps">
{steps.map((label, idx) => {
const isActive = idx <= currentStep
return (
<React.Fragment key={idx}>
{idx > 0 && <div className="xx-clonemodal-step-connector" />}
<div className={`xx-clonemodal-step${isActive ? " xx-clonemodal-step--active" : ""}`}>
<div className="xx-clonemodal-step-number">{idx + 1}</div>
<span className="xx-clonemodal-step-label">{label}</span>
</div>
</React.Fragment>
)
})}
</div>
)
}
export default StepIndicator
@@ -1,79 +0,0 @@
import React, { useRef } from "react"
import { ACCEPTED_MIME } from "../constants/cloneModal"
import { validateFile } from "../utils/cloneModal"
interface UploadZoneProps {
selectedFile: File | null
dragActive: boolean
onDragActiveChange: (active: boolean) => void
onFileSelect: (file: File | null, error: string) => void
}
const UploadZone: React.FC<UploadZoneProps> = ({
selectedFile,
dragActive,
onDragActiveChange,
onFileSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const error = validateFile(file)
onFileSelect(error ? null : file, error || "")
}
e.target.value = ""
}
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.type === "dragenter" || e.type === "dragover") {
onDragActiveChange(true)
} else if (e.type === "dragleave") {
onDragActiveChange(false)
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
onDragActiveChange(false)
const file = e.dataTransfer.files?.[0]
if (file) {
const error = validateFile(file)
onFileSelect(error ? null : file, error || "")
}
}
return (
<div
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
<p className="xx-clonemodal-upload-title">
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-clonemodal-upload-hint"> MP3WAVM4A 10MB</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME}
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
)
}
export default UploadZone
@@ -1,29 +0,0 @@
import type { ProgressStep } from "../types/cloneModal"
/** 进度阶段配置 */
export const PROGRESS_STEPS: ProgressStep[] = [
{ key: "uploading", label: "上传中", icon: "📤" },
{ key: "cloning", label: "克隆中", icon: "🧬" },
{ key: "done", label: "完成", icon: "✅" },
]
/** 支持的音频扩展名 */
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
/** input accept 属性值 */
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
/** 最大文件大小:10MB */
export const MAX_FILE_SIZE = 10 * 1024 * 1024
/** 最长录制时长(秒):5 分钟 */
export const MAX_RECORD_SECONDS = 5 * 60
/** 音色名称最小长度 */
export const MIN_VOICE_NAME_LENGTH = 2
/** 音色名称最大长度 */
export const MAX_VOICE_NAME_LENGTH = 20
/** 音色描述最大长度 */
export const MAX_VOICE_DESC_LENGTH = 100
@@ -1,119 +0,0 @@
import { useState, useRef, useCallback, useEffect } from "react"
import { MAX_RECORD_SECONDS } from "../constants/cloneModal"
interface UseAudioRecorderReturn {
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
toggleRecording: () => void
resetRecording: () => void
}
/**
* 录音 Hook —— 封装 MediaRecorder 录音逻辑
*/
const useAudioRecorder = (): UseAudioRecorderReturn => {
const [isRecording, setIsRecording] = useState(false)
const [recordTime, setRecordTime] = useState(0)
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const stopRecording = useCallback(() => {
setIsRecording(false)
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
}, [])
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mediaRecorder = new MediaRecorder(stream)
mediaRecorderRef.current = mediaRecorder
audioChunksRef.current = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
setRecordedBlob(blob)
stream.getTracks().forEach((track) => track.stop())
}
mediaRecorder.start()
setIsRecording(true)
setRecordTime(0)
setRecordedBlob(null)
recordTimerRef.current = setInterval(() => {
setRecordTime((prev) => {
const next = prev + 1
if (next >= MAX_RECORD_SECONDS) {
setTimeout(() => {
stopRecording()
}, 0)
return MAX_RECORD_SECONDS
}
return next
})
}, 1000)
} catch {
// 错误由调用方通过其他机制提示
setIsRecording(false)
}
}, [stopRecording])
const toggleRecording = useCallback(() => {
if (isRecording) {
stopRecording()
} else {
startRecording()
}
}, [isRecording, startRecording, stopRecording])
const resetRecording = useCallback(() => {
setIsRecording(false)
setRecordTime(0)
setRecordedBlob(null)
audioChunksRef.current = []
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
}, [])
// 卸载时清理
useEffect(() => {
return () => {
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
}
}, [])
return {
isRecording,
recordTime,
recordedBlob,
toggleRecording,
resetRecording,
}
}
export default useAudioRecorder
@@ -1,134 +0,0 @@
import { useState, useRef, useCallback, useEffect } from "react"
import type { ModalPhase } from "../types/cloneModal"
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
import useAudioRecorder from "./useAudioRecorder"
/**
* 克隆弹窗表单状态 Hook
* 管理表单字段、录音、文件选择、验证逻辑
*/
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
const [phase, setPhase] = useState<ModalPhase>("input")
const [voiceName, setVoiceName] = useState("")
const [voiceDescription, setVoiceDescription] = useState("")
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
const [errorMessage, setErrorMessage] = useState("")
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
useAudioRecorder()
/** 默认音色名称计数器 */
const cloneCounterRef = useRef(1)
const getNextDefaultName = useCallback((): string => {
const name = `我的声音 ${cloneCounterRef.current}`
cloneCounterRef.current += 1
return name
}, [])
const hasAudio = selectedFile !== null || recordedBlob !== null
const canSubmit =
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
hasAudio
const isProcessing = phase === "uploading" || phase === "cloning"
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input")
setVoiceName(getNextDefaultName())
setVoiceDescription("")
setSelectedFile(null)
setDragActive(false)
setErrorMessage("")
resetRecording()
}, [getNextDefaultName, resetRecording])
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState()
onClose()
}, [resetState, onClose])
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState()
}
}, [open, resetState])
/** 选择文件(来自上传或拖拽) */
const handleFileSelect = useCallback(
(file: File | null, error: string) => {
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("")
setSelectedFile(file)
// 清除录音
resetRecording()
}
},
[resetRecording],
)
/** 录音切换 */
const handleRecordToggle = useCallback(() => {
setErrorMessage("")
if (isRecording) {
toggleRecording()
} else {
// 开始录制前清除已选文件
setSelectedFile(null)
toggleRecording()
}
}, [isRecording, toggleRecording])
/** 表单验证 */
const validateForm = useCallback((): string | null => {
const name = voiceName.trim()
if (!name) {
return "请输入音色名称"
}
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
}
if (!hasAudio) {
return "请上传音频文件或录制一段声音"
}
return null
}, [voiceName, hasAudio])
return {
// 状态
phase,
setPhase,
voiceName,
setVoiceName,
voiceDescription,
setVoiceDescription,
selectedFile,
dragActive,
setDragActive,
errorMessage,
setErrorMessage,
// 录音
isRecording,
recordTime,
recordedBlob,
// 计算属性
hasAudio,
canSubmit,
isProcessing,
// handlers
handleFileSelect,
handleRecordToggle,
handleClose,
validateForm,
resetState,
}
}
@@ -1,46 +0,0 @@
import type { CloneModalProps } from "../types/cloneModal"
import { useCloneFormState } from "./useCloneFormState"
import { useCloneSubmit } from "./useCloneSubmit"
/**
* 音色克隆弹窗主业务 Hook
* 组合表单状态 + 提交流程两个子 Hook
*/
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
const formState = useCloneFormState({ open, onClose })
const { handleSubmit } = useCloneSubmit({
voiceName: formState.voiceName,
voiceDescription: formState.voiceDescription,
selectedFile: formState.selectedFile,
recordedBlob: formState.recordedBlob,
setPhase: formState.setPhase,
setErrorMessage: formState.setErrorMessage,
validateForm: formState.validateForm,
onSuccess,
onClose: formState.handleClose,
})
return {
phase: formState.phase,
voiceName: formState.voiceName,
voiceDescription: formState.voiceDescription,
selectedFile: formState.selectedFile,
dragActive: formState.dragActive,
errorMessage: formState.errorMessage,
isRecording: formState.isRecording,
recordTime: formState.recordTime,
recordedBlob: formState.recordedBlob,
canSubmit: formState.canSubmit,
isProcessing: formState.isProcessing,
setVoiceName: formState.setVoiceName,
setVoiceDescription: formState.setVoiceDescription,
setDragActive: formState.setDragActive,
handleFileSelect: formState.handleFileSelect,
handleRecordToggle: formState.handleRecordToggle,
handleClose: formState.handleClose,
handleSubmit,
}
}
export default useCloneModal
@@ -1,108 +0,0 @@
import { useRef, useCallback, useEffect } from "react"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { VoiceClone } from "@/api/voice-clone"
interface UseCloneSubmitOptions {
voiceName: string
voiceDescription: string
selectedFile: File | null
recordedBlob: Blob | null
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
setErrorMessage: (msg: string) => void
validateForm: () => string | null
onSuccess?: (clone: VoiceClone) => void
onClose: () => void
}
/**
* 克隆提交流程 Hook
* 封装上传 + 克隆 + 完成的三阶段流程
*/
export function useCloneSubmit({
voiceName,
voiceDescription,
selectedFile,
recordedBlob,
setPhase,
setErrorMessage,
validateForm,
onSuccess,
onClose,
}: UseCloneSubmitOptions) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 组件卸载时清理定时器 */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
const handleSubmit = useCallback(async () => {
const formError = validateForm()
if (formError) {
setErrorMessage(formError)
return
}
setErrorMessage("")
try {
// 阶段 1:上传音频
setPhase("uploading")
let fileToUpload: File
if (selectedFile) {
fileToUpload = selectedFile
} else {
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
type: "audio/webm",
})
}
// 获取默认项目和素材库
const project = await getOrCreateDefaultProject()
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
// 直传到 OSS
const uploadResult = await uploadAssetDirect({
file: fileToUpload,
library_id: library.id,
})
// 阶段 2:克隆
setPhase("cloning")
const result = await createVoiceClone({
name: voiceName.trim(),
description: voiceDescription.trim() || undefined,
audio_url: uploadResult.url,
})
// 阶段 3:完成
setPhase("done")
// 2秒后自动关闭
timerRef.current = setTimeout(() => {
onSuccess?.(toVoiceClone(result))
onClose()
}, 2000)
} catch (err) {
setPhase("input")
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
}
}, [
validateForm,
selectedFile,
recordedBlob,
voiceName,
voiceDescription,
setPhase,
setErrorMessage,
onSuccess,
onClose,
])
return { handleSubmit }
}
@@ -1,20 +0,0 @@
import type { VoiceClone } from "@/api/voice-clone"
/** 弹窗阶段 */
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
export interface CloneModalProps {
/** 弹窗是否可见 */
open: boolean
/** 关闭弹窗回调 */
onClose: () => void
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void
}
/** 进度步骤项 */
export interface ProgressStep {
key: string
label: string
icon: string
}
@@ -1,25 +0,0 @@
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "../constants/cloneModal"
/**
* 格式化录制时间 mm:ss
*/
export const formatRecordTime = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
}
/**
* 验证上传的音频文件
* @returns 错误信息,null 表示验证通过
*/
export const validateFile = (file: File): string | null => {
const ext = file.name.split(".").pop()?.toLowerCase()
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
}
if (file.size > MAX_FILE_SIZE) {
return "文件大小超过 10MB,请压缩后重试"
}
return null
}
+37 -5
View File
@@ -20,6 +20,7 @@ import LibrarySidebar from "@/pages/assets/components/LibrarySidebar"
import AssetFilterBar from "@/pages/assets/components/AssetFilterBar"
import BatchOperationBar from "@/pages/assets/components/BatchOperationBar"
import AssetUploadZone from "@/pages/assets/components/AssetUploadZone"
import UploadQueuePanel from "@/pages/assets/components/UploadQueuePanel"
import AssetGridSection from "@/pages/assets/components/AssetGridSection"
import AssetModals from "@/pages/assets/components/AssetModals"
import { useAssetsData } from "@/pages/assets/hooks/useAssetsData"
@@ -69,7 +70,30 @@ const AssetLibrary: React.FC = () => {
})
/* ── 上传 ── */
const { uploading, uploadProgress, handleUpload } = useAssetUpload({ effectiveLibId })
const {
uploadItems,
enqueueUploads,
retryUpload,
removeUpload,
clearFinished,
uploading,
activeCount,
pendingCount,
} = useAssetUpload({ effectiveLibId })
/* ── 上传中 asset_id → 进度/状态映射,合并进网格卡片展示真实进度 ── */
const uploadProgressMap = React.useMemo(() => {
const map = new Map<string, { progress: number; uploading: boolean }>()
for (const it of uploadItems) {
if (it.assetId && (it.status === "uploading" || it.status === "ingesting")) {
map.set(it.assetId, {
progress: it.status === "ingesting" ? 100 : it.progress,
uploading: it.status === "uploading",
})
}
}
return map
}, [uploadItems])
/* ── 选中态管理 ── */
const { selectedIds, setSelectedIds, toggleSelect, selectAll, deselectAll } = useAssetSelection({
@@ -144,8 +168,17 @@ const AssetLibrary: React.FC = () => {
{/* 上传区域 */}
<AssetUploadZone
uploading={uploading}
uploadProgress={uploadProgress}
onUpload={handleUpload}
activeCount={activeCount}
pendingCount={pendingCount}
onUpload={enqueueUploads}
/>
{/* 上传队列:独立进度 + 失败重试/移除 */}
<UploadQueuePanel
items={uploadItems}
onRetry={retryUpload}
onRemove={removeUpload}
onClearFinished={clearFinished}
/>
{/* 筛选栏 */}
@@ -180,6 +213,7 @@ const AssetLibrary: React.FC = () => {
assets={filteredAssets}
selectedIds={selectedIds}
diagnosingId={diagnosingId}
uploadProgressMap={uploadProgressMap}
onRetry={refetchAssets}
onToggleSelect={toggleSelect}
onDiagnose={handleDiagnose}
@@ -191,8 +225,6 @@ const AssetLibrary: React.FC = () => {
{/* ─── 弹窗集合 ─── */}
<AssetModals
uploading={uploading}
uploadProgress={uploadProgress}
createModalOpen={createModalOpen}
onCreateModalCancel={() => setCreateModalOpen(false)}
onCreateModalOk={handleCreateLibrary}
+244 -73
View File
@@ -147,44 +147,52 @@
/* ============================================================
上传区域
============================================================ */
.xx-asset-upload-zone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-2xl) var(--space-xl);
text-align: center;
background: var(--bg-secondary);
cursor: pointer;
.xx-asset-upload-entry {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
padding: var(--space-sm) var(--space-md);
border: 1px dashed transparent;
border-radius: var(--radius-md);
transition: var(--transition-all);
}
.xx-asset-upload-zone:hover {
.xx-asset-upload-entry-dragover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-asset-upload-zone:active {
border-style: solid;
transform: scale(0.99);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06);
}
.xx-asset-upload-icon {
font-size: 40px;
margin-bottom: var(--space-sm);
color: var(--primary-color);
}
.xx-asset-upload-text {
font-size: var(--font-size-base) !important;
color: var(--text-primary) !important;
margin: 0 0 var(--space-xs) !important;
.xx-asset-upload-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 6px 16px;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-inverse);
background: var(--primary-color);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition-all);
white-space: nowrap;
}
.xx-asset-upload-hint {
font-size: var(--font-size-sm) !important;
color: var(--text-tertiary) !important;
margin: 0 !important;
.xx-asset-upload-btn:hover {
opacity: 0.9;
}
.xx-asset-upload-btn:active {
transform: scale(0.98);
}
.xx-asset-upload-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* ============================================================
@@ -215,7 +223,7 @@
============================================================ */
.xx-asset-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(6, 1fr);
gap: 14px;
}
@@ -234,7 +242,7 @@
.xx-asset-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
transform: translateY(-2px);
transform: translateY(-1px);
}
.xx-asset-card:active {
@@ -244,7 +252,7 @@
/* 缩略图 */
.xx-asset-thumb {
aspect-ratio: 9 / 16;
aspect-ratio: 3 / 4;
position: relative;
overflow: hidden;
display: grid;
@@ -260,22 +268,22 @@
}
.xx-asset-thumb-placeholder {
font-size: var(--font-size-3xl);
font-size: var(--font-size-xl);
opacity: 0.7;
}
/* 播放按钮 */
.xx-asset-play {
position: absolute;
width: 40px;
height: 40px;
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
color: var(--text-inverse);
font-size: var(--font-size-md);
font-size: var(--font-size-sm);
transition: var(--transition-all);
}
@@ -332,8 +340,8 @@
position: absolute;
bottom: var(--space-sm, 8px);
right: var(--space-sm, 8px);
width: 28px;
height: 28px;
width: 24px;
height: 24px;
border-radius: var(--radius-full, 999px);
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
@@ -380,12 +388,12 @@
/* 卡片信息 */
.xx-asset-info {
padding: 12px;
padding: 8px;
}
.xx-asset-name {
margin: 0 0 6px;
font-size: var(--font-size-sm);
margin: 0 0 4px;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
@@ -397,9 +405,67 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-bottom: var(--space-sm);
margin-bottom: 4px;
min-width: 0;
}
/* 状态标签行:标签过长省略 */
.xx-asset-meta-status {
display: inline-flex;
align-items: center;
min-width: 0;
}
.xx-asset-meta-status .xx-status-pill {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xx-asset-meta-duration {
flex-shrink: 0;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
/* 余量标签独占一行 */
.xx-asset-meta-usage {
justify-content: flex-start;
margin-bottom: var(--space-xs);
}
/* 视频素材余量角标(仅状态展示,不影响卡片操作) */
.xx-asset-usage-badge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: var(--radius-full);
font-size: 10px;
font-weight: var(--font-weight-medium);
line-height: 1.5;
white-space: nowrap;
}
/* 已用尽:红色实心 */
.xx-asset-usage-badge-exhausted {
background: var(--error-color);
color: var(--text-inverse);
}
/* 即将用尽:红色软底 */
.xx-asset-usage-badge-warning {
background: var(--error-soft);
color: var(--error-color);
}
/* 已用 xx%:橙色软底 */
.xx-asset-usage-badge-ratio {
background: var(--warning-soft);
color: var(--warning-color);
}
/* 诊断按钮 */
@@ -443,12 +509,16 @@
.xx-status-pill {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xxs) var(--space-sm);
gap: 2px;
padding: 1px 6px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-size: 10px;
font-weight: var(--font-weight-medium);
line-height: 1.5;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-status-pill-ok {
@@ -592,7 +662,7 @@
============================================================ */
.xx-assets-skeleton-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(6, 1fr);
gap: 14px;
}
@@ -607,6 +677,10 @@
.xx-asset-grid {
grid-template-columns: repeat(3, 1fr);
}
.xx-assets-skeleton-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1200px) {
@@ -630,6 +704,10 @@
.xx-asset-grid {
grid-template-columns: repeat(3, 1fr);
}
.xx-assets-skeleton-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
@@ -641,6 +719,10 @@
grid-template-columns: repeat(2, 1fr);
}
.xx-assets-skeleton-grid {
grid-template-columns: repeat(2, 1fr);
}
.xx-assets-filters {
flex-direction: column;
align-items: stretch;
@@ -660,43 +742,132 @@
.xx-asset-grid {
grid-template-columns: 1fr;
}
.xx-assets-skeleton-grid {
grid-template-columns: 1fr;
}
}
/* ─── 上传进度弹窗 ─── */
.xx-upload-progress-modal .ant-modal-content {
padding: 24px 16px 20px;
border-radius: 16px;
/* ─── 上传队列面板 ─── */
.xx-upload-queue {
margin-top: 12px;
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 12px;
background: #fff;
overflow: hidden;
}
.xx-upload-progress-body {
.xx-upload-queue-header {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 8px 0;
padding: 10px 14px;
border-bottom: 1px solid var(--border-primary, #eef2f7);
background: #f8fafc;
}
.xx-upload-progress-ring {
display: block;
}
.xx-upload-progress-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.xx-upload-progress-pct {
font-size: 22px;
font-weight: 700;
color: var(--primary-color, #6366f1);
line-height: 1;
}
.xx-upload-progress-label {
.xx-upload-queue-title {
font-size: 13px;
color: var(--text-secondary, #6b7280);
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-upload-queue-list {
max-height: 240px;
overflow-y: auto;
}
.xx-upload-queue-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
}
.xx-upload-queue-item + .xx-upload-queue-item {
border-top: 1px solid var(--border-primary, #f1f5f9);
}
.xx-upload-queue-icon {
padding-top: 2px;
font-size: 15px;
}
.xx-upload-queue-body {
flex: 1;
min-width: 0;
}
.xx-upload-queue-name {
font-size: 13px;
color: var(--text-primary, #1e293b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-upload-queue-progress {
margin-top: 6px;
height: 4px;
border-radius: 2px;
background: var(--border-primary, #e5e7eb);
overflow: hidden;
}
.xx-upload-queue-progress-bar {
height: 100%;
border-radius: 2px;
background: var(--primary-color, #6366f1);
transition: width 0.25s ease;
}
.xx-upload-queue-status {
margin-top: 4px;
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
}
.xx-upload-queue-error .xx-upload-queue-status {
color: #ef4444;
}
.xx-upload-queue-actions {
display: flex;
gap: 6px;
}
.xx-upload-queue-btn {
border: none;
background: none;
cursor: pointer;
font-size: 13px;
color: var(--text-tertiary, #94a3b8);
padding: 2px 4px;
}
.xx-upload-queue-btn:hover {
color: var(--primary-color, #6366f1);
}
/* ─── 素材卡片上传中遮罩进度条 ─── */
.xx-asset-thumb-uploading {
flex-direction: column;
gap: 8px;
}
.xx-asset-upload-bar {
width: 70%;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.35);
overflow: hidden;
}
.xx-asset-upload-bar-inner {
height: 100%;
border-radius: 2px;
background: #fff;
transition: width 0.25s ease;
}
/* ─── 批量打标签弹窗 ─── */
@@ -8,7 +8,7 @@ import {
CloseCircleOutlined,
} from "@ant-design/icons"
import { Popconfirm } from "antd"
import type { AssetItem } from "@/pages/assets/types"
import { getUsageBadge, type AssetItem } from "@/pages/assets/types"
import { thumbGradient } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon"
import { StatusPill } from "./AssetSkeleton"
@@ -20,6 +20,8 @@ export interface AssetCardProps {
asset: AssetItem
selected: boolean
diagnosing?: boolean
/** 上传中实时进度(仅 uploading 态有值;ingesting 后由后端状态接管) */
uploadProgress?: { progress: number; uploading: boolean }
onToggle: () => void
onDiagnose: () => void
onPlay: () => void
@@ -30,99 +32,128 @@ const AssetCard: React.FC<AssetCardProps> = ({
asset,
selected,
diagnosing,
uploadProgress,
onToggle,
onDiagnose,
onPlay,
onDelete,
}) => (
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
{/* 缩略图区 */}
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
{asset.thumbUrl ? (
<img src={asset.thumbUrl} alt={asset.name} />
) : (
<span className="xx-asset-thumb-placeholder">
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
</span>
)}
}) => {
const isUploading = !!uploadProgress?.uploading
// 视频素材余量角标(已用尽/即将用尽/已用 xx%);非视频或字段缺失返回 null
const usageBadge = getUsageBadge(asset)
return (
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
{/* 缩略图区 */}
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
{asset.thumbUrl ? (
<img src={asset.thumbUrl} alt={asset.name} />
) : (
<span className="xx-asset-thumb-placeholder">
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
</span>
)}
{/* 处理中遮罩 */}
{asset.loading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
{/* 上传中遮罩:真实进度百分比 + 进度条 */}
{isUploading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-uploading">
<LoadingOutlined />
<span> {uploadProgress?.progress ?? 0}%</span>
<div className="xx-asset-upload-bar">
<div
className="xx-asset-upload-bar-inner"
style={{ width: `${uploadProgress?.progress ?? 0}%` }}
/>
</div>
</div>
)}
{/* 转码/处理中遮罩 */}
{asset.loading && !isUploading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
</div>
)}
{/* 失败状态标识 */}
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
<CloseCircleOutlined />
<span></span>
</div>
)}
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
<span
className="xx-asset-play"
onClick={(e) => {
e.stopPropagation()
onPlay()
}}
>
<PlayCircleOutlined />
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation()
onDelete()
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<span className="xx-asset-meta-status">
<StatusPill status={asset.status} label={asset.statusLabel} />
</span>
{asset.duration && <span className="xx-asset-meta-duration">{asset.duration}</span>}
</div>
)}
{/* 失败状态标识 */}
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
<CloseCircleOutlined />
<span></span>
</div>
)}
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
<span
className="xx-asset-play"
{usageBadge && (
<div className="xx-asset-meta xx-asset-meta-usage">
<span className={`xx-asset-usage-badge xx-asset-usage-badge-${usageBadge.variant}`}>
{usageBadge.label}
</span>
</div>
)}
<button
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing || asset.loading || asset.status === "bad"}
onClick={(e) => {
e.stopPropagation()
onPlay()
onDiagnose()
}}
>
<PlayCircleOutlined />
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation()
onDelete()
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<StatusPill status={asset.status} label={asset.statusLabel} />
{asset.duration && <span>{asset.duration}</span>}
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
<button
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing || asset.loading || asset.status === "bad"}
onClick={(e) => {
e.stopPropagation()
onDiagnose()
}}
>
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
</div>
)
)
}
export default AssetCard
@@ -8,6 +8,9 @@ import type { AssetItem } from "../types"
import AssetCard from "./AssetCard"
import { SkeletonCard } from "./AssetSkeleton"
/** 上传中素材的实时进度(asset_id → 进度信息),由上传队列合并到卡片 */
export type UploadProgressMap = Map<string, { progress: number; uploading: boolean }>
export interface AssetGridSectionProps {
loading: boolean
error: boolean
@@ -15,6 +18,7 @@ export interface AssetGridSectionProps {
assets: AssetItem[]
selectedIds: Set<string>
diagnosingId: string | null
uploadProgressMap?: UploadProgressMap
onRetry?: () => void
onToggleSelect: (id: string) => void
onDiagnose: (asset: AssetItem) => void
@@ -29,6 +33,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
assets,
selectedIds,
diagnosingId,
uploadProgressMap,
onRetry,
onToggleSelect,
onDiagnose,
@@ -70,6 +75,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
asset={asset}
selected={selectedIds.has(asset.id)}
diagnosing={diagnosingId === asset.id}
uploadProgress={uploadProgressMap?.get(asset.id)}
onToggle={() => onToggleSelect(asset.id)}
onDiagnose={() => onDiagnose(asset)}
onPlay={() => onPlay(asset)}
@@ -11,12 +11,9 @@ import BatchTagModal from "./BatchTagModal"
import BatchClassifyModal from "./BatchClassifyModal"
import BatchMarkModal from "./BatchMarkModal"
import ResultDrawer from "./ResultDrawer"
import UploadProgressModal from "./UploadProgressModal"
export interface AssetModalsProps {
/* 上传进度 */
uploading: boolean
uploadProgress: number
/* 新建视频库 */
createModalOpen: boolean
@@ -68,8 +65,6 @@ export interface AssetModalsProps {
}
export const AssetModals: React.FC<AssetModalsProps> = ({
uploading,
uploadProgress,
createModalOpen,
onCreateModalCancel,
onCreateModalOk,
@@ -109,9 +104,6 @@ export const AssetModals: React.FC<AssetModalsProps> = ({
}) => {
return (
<>
{/* 上传进度弹窗 */}
<UploadProgressModal open={uploading} progress={uploadProgress} />
{/* 新建视频库弹窗 */}
<CreateLibraryModal
open={createModalOpen}
@@ -1,37 +1,91 @@
/**
* AssetLibrary 上传拖拽区域
* AssetLibrary 上传入口(紧凑按钮模式)
* - 点击按钮打开文件选择(多选),多文件入队由 useAssetUpload 队列控制(最多 3 路直传)
* - 拖拽文件到内容区任意位置同样触发上传(不再占用大面积虚线框)
*/
import React from "react"
import { Upload } from "antd"
import { InboxOutlined } from "@ant-design/icons"
import React, { useRef, useState } from "react"
import { PlusOutlined, CloudUploadOutlined } from "@ant-design/icons"
export interface AssetUploadZoneProps {
uploading: boolean
uploadProgress: number
onUpload: (file: File) => void
activeCount: number
pendingCount: number
onUpload: (files: File[]) => void
}
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
uploading,
activeCount,
pendingCount,
onUpload,
}) => {
const inputRef = useRef<HTMLInputElement>(null)
// dragenter/dragleave 在经过子元素时会成对触发,用计数器避免高亮闪烁;
// 计数器归零(拖拽真正离开容器)才取消高亮
const dragDepthRef = useRef(0)
const [dragOver, setDragOver] = useState(false)
const pickFiles = (list: FileList | null) => {
if (!list || list.length === 0) return
onUpload(Array.from(list))
}
return (
<Upload.Dragger
beforeUpload={(file) => {
onUpload(file as File)
return false
<div
className={`xx-asset-upload-entry${dragOver ? " xx-asset-upload-entry-dragover" : ""}`}
onDragEnter={(e) => {
e.preventDefault()
dragDepthRef.current += 1
setDragOver(true)
}}
onDragOver={(e) => {
e.preventDefault()
}}
onDragLeave={(e) => {
e.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setDragOver(false)
}
}}
onDrop={(e) => {
e.preventDefault()
dragDepthRef.current = 0
setDragOver(false)
pickFiles(e.dataTransfer.files)
}}
showUploadList={false}
multiple
accept="video/*,image/*"
>
<div className="xx-asset-upload-zone">
<p className="xx-asset-upload-icon">
<InboxOutlined />
</p>
<p className="xx-asset-upload-text">
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
</p>
<p className="xx-asset-upload-hint"> 2GB</p>
</div>
</Upload.Dragger>
<button
type="button"
className="xx-asset-upload-btn"
onClick={() => inputRef.current?.click()}
>
<PlusOutlined />
</button>
<span className="xx-asset-upload-status">
{uploading ? (
<>
<CloudUploadOutlined />
{activeCount} {pendingCount > 0 ? `,排队 ${pendingCount}` : ""}
</>
) : (
"视频、图片均可,单文件不超过 2GB;也可直接拖拽文件到此区域"
)}
</span>
<input
ref={inputRef}
type="file"
multiple
accept="video/*,image/*"
style={{ display: "none" }}
onChange={(e) => {
pickFiles(e.target.files)
// 允许连续选择同一文件
e.target.value = ""
}}
/>
</div>
)
}
@@ -1,56 +0,0 @@
import React from "react"
import { Modal as AntModal } from "antd"
/* ============================================================
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
* ============================================================ */
export interface UploadProgressModalProps {
open: boolean
progress: number
}
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
<AntModal
open={open}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
{/* 背景圆环 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{progress}%</span>
<span className="xx-upload-progress-label"></span>
</div>
</div>
</AntModal>
)
export default UploadProgressModal
@@ -0,0 +1,117 @@
/**
* 上传队列面板
* 展示批量上传中每个文件的独立状态/进度;失败可重试、可移除、可清空已完成。
* 上传中的素材卡片同时也会出现在素材网格(后端 prepare 预建 asset),
* 此面板用于展示真实传输进度与失败重试入口。
*/
import React from "react"
import {
LoadingOutlined,
CheckCircleFilled,
CloseCircleFilled,
ReloadOutlined,
CloseOutlined,
} from "@ant-design/icons"
import type { UploadItem } from "../hooks/useAssetUpload"
export interface UploadQueuePanelProps {
items: UploadItem[]
onRetry: (tempId: string) => void
onRemove: (tempId: string) => void
onClearFinished: () => void
}
const STATUS_TEXT: Record<UploadItem["status"], string> = {
preparing: "排队中…",
uploading: "上传中",
ingesting: "转码中…",
done: "已完成",
error: "上传失败",
}
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
items,
onRetry,
onRemove,
onClearFinished,
}) => {
if (items.length === 0) return null
const finishedCount = items.filter((it) => it.status === "done").length
return (
<div className="xx-upload-queue">
<div className="xx-upload-queue-header">
<span className="xx-upload-queue-title">
{items.length}
{finishedCount > 0 ? `,已完成 ${finishedCount}` : ""}
</span>
{finishedCount > 0 && (
<button type="button" className="xx-link-btn" onClick={onClearFinished}>
</button>
)}
</div>
<div className="xx-upload-queue-list">
{items.map((it) => {
const isActive = it.status === "preparing" || it.status === "uploading"
const showProgress = it.status === "uploading" || it.status === "ingesting"
return (
<div key={it.tempId} className={`xx-upload-queue-item xx-upload-queue-${it.status}`}>
<span className="xx-upload-queue-icon">
{it.status === "done" || it.duplicated ? (
<CheckCircleFilled style={{ color: "#22c55e" }} />
) : it.status === "error" ? (
<CloseCircleFilled style={{ color: "#ef4444" }} />
) : (
<LoadingOutlined style={{ color: "var(--primary-color)" }} />
)}
</span>
<div className="xx-upload-queue-body">
<div className="xx-upload-queue-name" title={it.fileName}>
{it.fileName}
</div>
{showProgress ? (
<div className="xx-upload-queue-progress">
<div
className="xx-upload-queue-progress-bar"
style={{ width: `${it.status === "ingesting" ? 100 : it.progress}%` }}
/>
</div>
) : null}
<div className="xx-upload-queue-status">
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
{it.status === "uploading" ? ` ${it.progress}%` : ""}
{it.status === "error" && it.error ? `${it.error}` : ""}
</div>
</div>
<span className="xx-upload-queue-actions">
{it.status === "error" && (
<button
type="button"
className="xx-upload-queue-btn"
title="重试"
onClick={() => onRetry(it.tempId)}
>
<ReloadOutlined />
</button>
)}
{(it.status === "error" || it.status === "done") && !isActive && (
<button
type="button"
className="xx-upload-queue-btn"
title="移除"
onClick={() => onRemove(it.tempId)}
>
<CloseOutlined />
</button>
)}
</span>
</div>
)
})}
</div>
</div>
)
}
export default UploadQueuePanel
+175 -44
View File
@@ -1,65 +1,196 @@
import { useState, useCallback } from "react"
import { useState, useCallback, useRef, useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { uploadAssetDirect } from "@/api/assets"
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
import { MAX_FILE_SIZE } from "../constants"
/**
* 素材上传 Hook
* 封装上传状态、进度管理和上传逻辑
*/
interface UseAssetUploadProps {
effectiveLibId: string
/** 单文件上传状态机 */
export type UploadItemStatus = "preparing" | "uploading" | "ingesting" | "done" | "error"
export interface UploadItem {
/** 前端临时 idprepare 前无 asset_id 时用) */
tempId: string
file: File
fileName: string
/** 进度 0~100(仅直传阶段有真实进度) */
progress: number
status: UploadItemStatus
/** 后端 prepare 预建的 asset id(旧后端可能为空) */
assetId?: string
/** 去重命中:complete 返回 duplicated,标记完成但不产生新素材 */
duplicated?: boolean
error?: string
}
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
const MAX_CONCURRENT = 3
/**
* 素材批量上传 Hook
* - prepare 阶段后端预建 status=uploading 的 asset,前端拿到 asset_id 立即刷新列表
* - OSS 直传并发限制为 3,其余排队;每个文件独立进度/状态
* - complete 后素材进入转码(ingesting/processing),由列表轮询反映
* - 失败卡片支持重试/移除
*/
export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
const queryClient = useQueryClient()
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [items, setItems] = useState<UploadItem[]>([])
const itemsRef = useRef<UploadItem[]>([])
itemsRef.current = items
const handleUpload = useCallback(
async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
message.error(`文件 "${file.name}" 超过 2GB 限制`)
return
const updateItem = useCallback((tempId: string, patch: Partial<UploadItem>) => {
setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it)))
}, [])
/** 刷新素材列表(prepare 后/complete 后调用,让卡片即时出现/流转) */
const refreshList = useCallback(() => {
// 使用 refetchQueries 强制立即重新获取,避免 staleTime 导致延迟
if (effectiveLibId) {
queryClient.refetchQueries({ queryKey: ["assets", effectiveLibId] })
}
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
}, [queryClient, effectiveLibId])
/** 执行单个文件的完整上传流程(prepare→transfer→complete */
const runUpload = useCallback(
async (item: UploadItem, handle?: DirectUploadHandle) => {
try {
// 1. prepare(重试时复用已准备的 handle 也行,但签名可能过期,重新 prepare 最稳)
const h =
handle ??
(await prepareDirectUploadHandle({ file: item.file, library_id: effectiveLibId }))
if (h.prepared.asset_id) {
updateItem(item.tempId, {
status: "uploading",
assetId: h.prepared.asset_id,
progress: 0,
})
// 预建 asset 已入库,立即刷新让「上传中」卡片出现在网格
refreshList()
} else {
updateItem(item.tempId, { status: "uploading", progress: 0 })
}
// 2. OSS 直传(真实进度)
await h.transfer((pct) => updateItem(item.tempId, { progress: pct }))
// 3. complete:后端创建 ingest job,素材进入转码
updateItem(item.tempId, { status: "ingesting", progress: 100 })
const result = await h.complete()
refreshList()
if (result.duplicated) {
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
} else {
updateItem(item.tempId, { status: "done" })
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
}
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "上传失败"
console.error("[useAssetUpload] 上传失败:", item.fileName, err)
updateItem(item.tempId, { status: "error", error: detail })
message.error(`"${item.fileName}" 上传失败:${detail}`)
}
},
[effectiveLibId, refreshList, updateItem],
)
/**
* 队列调度:把并发槽塞满(同时在途的 prepare+transfer 不超过 MAX_CONCURRENT)。
* runUpload 在 await prepare 期间 state 仍是 preparing,多个并发 pump 若只看 state
* 会重复认领同一项,因此用 claimedRef 记录已被认领的 tempId。
*/
const inFlightRef = useRef(0)
const claimedRef = useRef<Set<string>>(new Set())
const pumpRef = useRef<() => void>(() => {})
pumpRef.current = () => {
while (inFlightRef.current < MAX_CONCURRENT) {
const next = itemsRef.current.find(
(it) => it.status === "preparing" && !claimedRef.current.has(it.tempId),
)
if (!next) return
claimedRef.current.add(next.tempId)
inFlightRef.current += 1
void runUpload(next).finally(() => {
inFlightRef.current -= 1
claimedRef.current.delete(next.tempId)
// 一个任务结束(成功/失败)后继续拉起排队任务
setTimeout(() => pumpRef.current(), 0)
})
}
}
useEffect(() => {
pumpRef.current()
}, [items])
/** 入队一个或多个文件 */
const enqueueUploads = useCallback(
(files: File[]) => {
if (!effectiveLibId) {
message.warning("请先选择或创建一个视频库")
return
}
setUploading(true)
setUploadProgress(0)
try {
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`)
const valid: File[] = []
for (const file of files) {
if (file.size > MAX_FILE_SIZE) {
message.error(`文件 "${file.name}" 超过 2GB 限制`)
continue
}
await uploadAssetDirect({
file,
library_id: effectiveLibId,
onProgress: (pct) => setUploadProgress(pct),
})
message.success(`"${file.name}" 上传成功`)
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : ""
console.error("[handleUpload] 上传失败:", err)
message.error(`"${file.name}" 上传失败${detail ? `${detail}` : ""}`)
// 错误时延迟关闭弹窗,让用户能看到错误提示
await new Promise((r) => setTimeout(r, 1500))
} finally {
setUploading(false)
setUploadProgress(0)
valid.push(file)
}
if (valid.length === 0) return
const newItems: UploadItem[] = valid.map((file, idx) => ({
tempId: `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
file,
fileName: file.name,
progress: 0,
status: "preparing",
}))
setItems((prev) => [...prev, ...newItems])
},
[effectiveLibId, queryClient],
[effectiveLibId],
)
/** 重试失败任务 */
const retryUpload = useCallback(
(tempId: string) => {
const target = itemsRef.current.find((it) => it.tempId === tempId)
if (!target) return
updateItem(tempId, { status: "preparing", progress: 0, error: undefined })
// 状态更新后由 useEffect 触发 pump
},
[updateItem],
)
/** 从上传列表移除(已进入转码的由素材网格管理;这里只移除上传面板记录) */
const removeUpload = useCallback((tempId: string) => {
setItems((prev) => prev.filter((it) => it.tempId !== tempId))
}, [])
/** 清空已完成/去重记录 */
const clearFinished = useCallback(() => {
setItems((prev) => prev.filter((it) => it.status !== "done"))
}, [])
const activeCount = items.filter(
(it) => it.status === "preparing" || it.status === "uploading",
).length
const pendingCount = items.filter((it) => it.status === "preparing").length
const hasActive = activeCount > 0 || items.some((it) => it.status === "ingesting")
return {
uploading,
uploadProgress,
handleUpload,
uploadItems: items,
enqueueUploads,
retryUpload,
removeUpload,
clearFinished,
/** 是否有进行中的上传(用于上传区文案) */
uploading: hasActive,
activeCount,
pendingCount,
}
}
@@ -45,11 +45,21 @@ export function useAssetsData() {
queryKey: ["assets", effectiveLibId],
queryFn: () =>
getAssets(effectiveLibId, {
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"上传中/处理中"的素材
status: "ready,uploading,ingesting,processing,pending,error,failed",
}),
enabled: !!effectiveLibId,
staleTime: 30_000,
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止
refetchInterval: (query) => {
const data = query.state.data as { items: ApiAssetItem[] } | undefined
const items = data?.items ?? []
const processing = items.some((a) => {
const st = a.status ?? ""
return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
})
return processing ? 3000 : false
},
})
const assets: AssetItem[] = useMemo(
+32
View File
@@ -27,6 +27,36 @@ export interface AssetItem {
duration?: string
size: number
createdAt: string
/** 已切片段占用时长占比(0~1),后端字段缺失时为 undefined */
usedRatio?: number
/** 是否已彻底用尽(false 的素材不参与生成选片),字段缺失时视为可用 */
usable?: boolean
}
/** 素材余量角标状态(仅视频素材) */
export interface UsageBadge {
/** 角标文案 */
label: string
/** 样式变体:exhausted=红色实心,warning=红色软底,ratio=橙色软底 */
variant: "exhausted" | "warning" | "ratio"
}
/**
* 根据后端余量字段计算视频素材的余量角标;
* 非视频、字段缺失或已用占比 <50% 时不显示(返回 null)。
*/
export const getUsageBadge = (asset: {
kind?: AssetKind
usable?: boolean
usedRatio?: number
}): UsageBadge | null => {
if (asset.kind && asset.kind !== "video") return null
if (asset.usable === false) return { label: "已用尽", variant: "exhausted" }
const ratio = asset.usedRatio
if (ratio == null) return null
if (ratio >= 0.85) return { label: "即将用尽", variant: "warning" }
if (ratio >= 0.5) return { label: `已用 ${Math.round(ratio * 100)}%`, variant: "ratio" }
return null
}
/** 根据 mime_type 推断前端 AssetKind */
@@ -111,5 +141,7 @@ export const mapAsset = (item: ApiAssetItem): AssetItem => {
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
usedRatio: item.used_ratio ?? undefined,
usable: item.usable ?? undefined,
}
}
@@ -98,8 +98,8 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
duration_min: c.duration,
duration_max: c.duration,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
@@ -62,8 +62,9 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
</div>
<ManualMaterialList
materials={m.materials}
materials={m.selectableMaterials}
materialsLoading={m.materialsLoading}
allExhausted={m.allMaterialsExhausted}
selectedMaterials={m.selectedMaterials}
onToggle={m.handleToggleMaterial}
/>
@@ -77,21 +78,11 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
onMatch={m.handleSmartMatch}
hasMatched={m.hasMatched}
onRefresh={m.handleRefreshMatch}
materialsCount={m.materials.items.length}
materialsCount={m.selectableMaterials.items.length}
loading={m.materialsLoading}
/>
<SmartMatchResults
matchedAssets={m.smartMatchedResults}
selectedIds={m.smartSelectedIds}
matching={m.smartMatching}
hasMatched={m.hasMatched}
onToggle={m.handleToggleSmartSelect}
onSelectAll={m.handleSelectAllMatched}
onClear={m.handleClearSmartSelect}
formatDuration={m.formatDuration}
selectedTotalDuration={m.smartSelectedTotalDuration}
/>
<SmartMatchResults matching={m.smartMatching} hasMatched={m.hasMatched} />
</div>
)}
</div>
@@ -27,6 +27,13 @@ const getFileSize = (item: AssetItem): number => {
return item.file_size ?? (item.metadata?.file_size as number) ?? 0
}
/** 是否为 AI 音色(克隆/预置音色模型:无固定时长、无实体音频文件,按脚本实时合成) */
const isAiVoice = (item: AssetItem): boolean => {
const duration = getDuration(item)
const size = getFileSize(item)
return (!duration || duration <= 0) && (!size || size <= 0)
}
const formatDuration = (seconds?: number): string => {
if (!seconds || seconds <= 0) return "00:00"
const m = Math.floor(seconds / 60)
@@ -96,10 +103,10 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
/** 选中素材(含时长校验) */
const handleSelect = useCallback(
(id: string) => {
// 如果启用了时长校验,且配音时长不足
// 如果启用了时长校验,且配音时长不足(AI 音色按脚本实时合成,不参与时长校验)
if (totalVideoDuration > 0) {
const material = materials.find((m) => m.id === id)
if (material && getDuration(material) < totalVideoDuration) {
if (material && !isAiVoice(material) && getDuration(material) < totalVideoDuration) {
setPendingVoiceId(id)
setDurationWarningOpen(true)
return
@@ -280,25 +287,29 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
alignItems: "center",
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
{formatDuration(getDuration(item))}
{totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && (
<span
style={{
color: "#ff4d4f",
fontSize: 11,
fontWeight: 500,
display: "inline-flex",
alignItems: "center",
gap: 2,
}}
>
<WarningOutlined />
</span>
)}
</span>
<span>{formatFileSize(getFileSize(item))}</span>
{isAiVoice(item) ? (
<span style={{ color: "#1677ff", fontWeight: 500 }}>AI </span>
) : (
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
{formatDuration(getDuration(item))}
{totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && (
<span
style={{
color: "#ff4d4f",
fontSize: 11,
fontWeight: 500,
display: "inline-flex",
alignItems: "center",
gap: 2,
}}
>
<WarningOutlined />
</span>
)}
</span>
)}
<span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span>
</div>
</div>
)
@@ -11,6 +11,8 @@ const { Text } = Typography
interface ManualMaterialListProps {
materials: { items: AssetItem[]; total: number }
materialsLoading: boolean
/** 库内有素材但全部已用尽(usable === false),用于区分空状态文案 */
allExhausted?: boolean
selectedMaterials: string[]
onToggle: (materialId: string) => void
}
@@ -247,6 +249,7 @@ const MaterialCard: React.FC<{
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
materials,
materialsLoading,
allExhausted,
selectedMaterials,
onToggle,
}) => {
@@ -256,7 +259,9 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}></Text>
) : materials.items.length === 0 ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
{allExhausted
? "暂无可选素材(素材可能已用尽,请先上传新素材)"
: "暂无素材,请先在视频库中上传"}
</Text>
) : (
<div
@@ -1,52 +0,0 @@
/**
* 智能匹配卡片(Q5 简化版)
* 只展示素材缩略图、名称、时长,无匹配分数
*/
import React from "react"
import { PlayCircleOutlined, CheckCircleFilled } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
interface SmartMatchCardProps {
asset: AssetItem
selected: boolean
onClick: () => void
formatDuration: (seconds: number) => string
}
const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
asset,
selected,
onClick,
formatDuration,
}) => {
return (
<div className={`xx-smart-match-card ${selected ? "selected" : ""}`} onClick={onClick}>
{/* 缩略图 */}
<div className="xx-smart-match-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} />
) : (
<div className="xx-smart-match-thumb-placeholder">
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
</div>
)}
{selected && (
<div className="xx-smart-match-check">
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
</div>
)}
{asset.duration && (
<div className="xx-smart-match-duration">{formatDuration(asset.duration)}</div>
)}
</div>
{/* 名称 */}
<div className="xx-smart-match-info">
<div className="xx-smart-match-name" title={asset.name}>
{asset.name}
</div>
</div>
</div>
)
}
export default SmartMatchCard
@@ -1,35 +1,17 @@
/**
* 智能匹配结果区(Q5 简化版)
* 直接展示 AI 选中的素材,无匹配分数和理由
* 智能匹配状态区
* 仅展示匹配中 / 未匹配 / 匹配成功三种状态,不展示 AI 选中的素材明细
* (选中的素材仍由 smartSelectedIds 驱动提交,逻辑不变)
*/
import React from "react"
import { LoadingOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import SmartMatchCard from "./SmartMatchCard"
interface SmartMatchResultsProps {
matchedAssets: AssetItem[]
selectedIds: string[]
matching: boolean
hasMatched: boolean
onToggle: (assetId: string) => void
onSelectAll: () => void
onClear: () => void
formatDuration: (seconds: number) => string
selectedTotalDuration: number
}
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
matchedAssets,
selectedIds,
matching,
hasMatched,
onToggle,
onSelectAll,
onClear,
formatDuration,
selectedTotalDuration,
}) => {
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({ matching, hasMatched }) => {
// 匹配中状态
if (matching) {
return (
@@ -45,66 +27,26 @@ const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
)
}
// 匹配状态提示
if (!hasMatched) {
// 匹配成功:轻量提示,不展示素材明细卡片
if (hasMatched) {
return (
<div className="xx-smart-match-empty">
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
AI
</div>
<div className="xx-smart-match-success">
<span style={{ fontSize: 16 }}></span>
<span style={{ color: "var(--text-secondary)", fontSize: 13 }}>
AI
</span>
</div>
)
}
// 无结果
if (matchedAssets.length === 0) return null
// 未匹配状态提示
return (
<>
<div className="xx-smart-match-results">
<div className="xx-smart-match-results-header">
<span className="xx-smart-match-results-title">
AI ({matchedAssets.length})
</span>
<div className="xx-smart-match-results-actions">
<button type="button" className="xx-link-btn" onClick={onSelectAll}>
</button>
<span style={{ color: "var(--border-color)" }}>|</span>
<button type="button" className="xx-link-btn" onClick={onClear}>
</button>
</div>
</div>
<div className="xx-smart-match-grid">
{matchedAssets.map((asset) => {
const isSelected = selectedIds.includes(asset.id)
return (
<SmartMatchCard
key={asset.id}
asset={asset}
selected={isSelected}
onClick={() => onToggle(asset.id)}
formatDuration={formatDuration}
/>
)
})}
</div>
<div className="xx-smart-match-empty">
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
AI
</div>
{/* 已选素材汇总 */}
{selectedIds.length > 0 && (
<div className="xx-smart-match-summary">
<div className="xx-smart-match-summary-header">
<span className="xx-pill xx-pill-ok"> {selectedIds.length} </span>
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>
{selectedTotalDuration.toFixed(0)}
</span>
</div>
</div>
)}
</>
</div>
)
}
+5 -157
View File
@@ -1409,154 +1409,15 @@
color: var(--text-tertiary, #94a3b8);
}
.xx-smart-match-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-smart-match-results-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.xx-smart-match-results-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-smart-match-results-actions {
.xx-smart-match-success {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
}
.xx-link-btn {
background: none;
border: none;
color: var(--primary-color, #4f46e5);
font-size: 12px;
cursor: pointer;
padding: 2px 4px;
}
.xx-link-btn:hover {
text-decoration: underline;
}
.xx-smart-match-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
.xx-smart-match-card {
background: #fff;
border: 2px solid var(--border-primary, #e2e8f0);
padding: 18px 20px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
}
.xx-smart-match-card:hover {
border-color: var(--primary-color, #4f46e5);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.xx-smart-match-card.selected {
border-color: var(--primary-color, #4f46e5);
background: var(--primary-soft, #eef2ff);
}
.xx-smart-match-thumb {
position: relative;
width: 100%;
aspect-ratio: 9 / 16;
background: #f1f5f9;
overflow: hidden;
}
.xx-smart-match-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.xx-smart-match-thumb-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary, #94a3b8);
}
.xx-smart-match-score {
position: absolute;
top: 8px;
left: 8px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
border-radius: 12px;
}
.xx-smart-match-check {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
background: var(--primary-color, #4f46e5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.xx-smart-match-duration {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 6px;
font-size: 11px;
color: #fff;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
}
.xx-smart-match-info {
padding: 10px 12px;
}
.xx-smart-match-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1e293b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.xx-smart-match-reason {
font-size: 11px;
color: var(--text-secondary, #64748b);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.xx-smart-match-loading {
@@ -1580,19 +1441,6 @@
text-align: center;
}
.xx-smart-match-summary {
padding: 12px 16px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 12px;
}
.xx-smart-match-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* ============================================================
AI 智能生成标题
============================================================ */
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"
import { useState, useEffect, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { getAssets, getAssetLibraries } from "@/api/assets"
import { getAssets, getAssetLibraries, isAssetUsable } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
/**
@@ -31,11 +31,26 @@ export function useMaterialLibrary() {
enabled: !!selectedLibraryId,
})
// 生成选片只展示仍可切出不重复片段的素材(usable !== false);
// 后端字段未上线时 isAssetUsable 恒为 true,过滤为 no-op
const selectableMaterials = useMemo(
() => ({
items: materials.items.filter(isAssetUsable),
total: materials.total,
}),
[materials],
)
// 库内有素材但全部已用尽(用于区分空状态文案)
const allMaterialsExhausted = materials.items.length > 0 && selectableMaterials.items.length === 0
return {
libraries,
selectedLibraryId,
setSelectedLibraryId,
materials,
selectableMaterials,
allMaterialsExhausted,
materialsLoading,
}
}
@@ -1,29 +1,48 @@
import { useState, useCallback, useMemo } from "react"
import { useState, useCallback } from "react"
import { message } from "antd"
import type { AssetItem } from "@/api/assets"
import { smartMatchAssets } from "@/api/assets"
import type { TemplateSegment } from "@/api/templates/types"
import { smartMatchAssets, isAssetUsable } from "@/api/assets"
interface UseSmartMatchOptions {
libraryId: string
materials: { items: AssetItem[]; total: number }
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
/** 当前模板的 segments,用于根据总时长计算 limit */
templateSegments?: TemplateSegment[]
}
/** 默认 limit(拿不到目标时长时的兜底上限) */
const DEFAULT_LIMIT = 10
/** 每个素材切片按 15 秒估算所需素材数 */
const SECONDS_PER_ASSET = 15
/**
* 根据模板 segments 计算所需素材数量上限。
* 取每个 segment 的 duration_min 之和作为目标视频总时长,
* 再按 15 秒/素材估算需要多少个素材;结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
*/
function computeLimitFromSegments(segments?: TemplateSegment[]): number {
if (!segments || segments.length === 0) return DEFAULT_LIMIT
const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0)
if (totalSeconds <= 0) return DEFAULT_LIMIT
const limit = Math.ceil(totalSeconds / SECONDS_PER_ASSET)
return Math.max(1, Math.min(limit, 200))
}
/**
* 智能素材匹配 HookQ5 简化版)
* 用户不手动选素材时,一键调用后端 AI 选素材
* 后端统一选素材逻辑后续完善,当前先走前端流程简化
* 智能素材匹配 Hook
* 用户不手动选素材时,一键调用后端 AI 选素材;匹配结果自动全量写入
* smartSelectedIds(由上层持有),不向用户展示素材明细。
*/
export function useSmartMatch({
libraryId,
materials,
smartSelectedIds,
onSmartSelectedIdsChange,
templateSegments,
}: UseSmartMatchOptions) {
const [smartMatching, setSmartMatching] = useState(false)
const [hasMatched, setHasMatched] = useState(false)
const [smartMatchedResults, setSmartMatchedResults] = useState<AssetItem[]>([])
/* ── 一键智能匹配 ── */
const handleSmartMatch = useCallback(async () => {
@@ -32,88 +51,57 @@ export function useSmartMatch({
return
}
if (materials.items.length === 0) {
message.warning("当前视频库暂无素材")
// 已用尽素材(usable === false)不参与智能匹配;
// 后端字段未上线时 isAssetUsable 恒为 true,过滤为 no-op
const usableItems = materials.items.filter(isAssetUsable)
if (usableItems.length === 0) {
message.warning(
materials.items.length === 0 ? "当前视频库暂无素材" : "素材可能已用尽,请先上传新素材",
)
return
}
setSmartMatching(true)
try {
// 调用后端智能匹配 API
const result = await smartMatchAssets(libraryId)
const matchedIds = result.items?.map((a: AssetItem) => a.id) ?? []
// 根据目标视频时长计算合理的素材数量上限,避免"有几个选几个"
const limit = computeLimitFromSegments(templateSegments)
// 调用后端智能匹配 API(后端也会排除已用尽素材,这里前端兜底过滤)
const result = await smartMatchAssets(libraryId, limit)
// 兜底过滤:id 为空或不可用的素材不参与匹配(smartMatchAssets 已做归一化,这里双保险)
const matched = (result.items ?? []).filter((a) => !!a?.id && isAssetUsable(a))
const matchedIds = matched.map((a: AssetItem) => a.id)
if (matchedIds.length > 0) {
onSmartSelectedIdsChange(matchedIds)
// 保存 API 返回的完整素材列表
const resolved = result.items?.length
? result.items
: materials.items.filter((a) => matchedIds.includes(a.id))
setSmartMatchedResults(resolved)
setHasMatched(true)
message.success(`AI 已为你选择 ${matchedIds.length} 个素材`)
} else {
// 后端返回空结果,回退到全选
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
setSmartMatchedResults(materials.items)
// 后端返回空结果,回退到全选可用素材
onSmartSelectedIdsChange(usableItems.map((a) => a.id))
setHasMatched(true)
message.info("AI 暂未找到匹配素材,已全选当前库素材")
}
} catch {
// 后端 API 尚未就绪时,回退到全选当前库素材
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
setSmartMatchedResults(materials.items)
// 后端 API 尚未就绪时,回退到全选当前库可用素材
onSmartSelectedIdsChange(usableItems.map((a) => a.id))
setHasMatched(true)
message.info("已为你全选当前库素材(智能匹配功能即将上线)")
} finally {
setSmartMatching(false)
}
}, [libraryId, materials.items, onSmartSelectedIdsChange])
}, [libraryId, materials.items, onSmartSelectedIdsChange, templateSegments])
/* ── 换一批 = 重新触发智能匹配 ── */
const handleRefreshMatch = useCallback(async () => {
// 换一批 = 重新触发智能匹配
await handleSmartMatch()
}, [handleSmartMatch])
const handleSelectAllMatched = useCallback(() => {
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
}, [materials.items, onSmartSelectedIdsChange])
const handleClearSmartSelect = useCallback(() => {
onSmartSelectedIdsChange([])
}, [onSmartSelectedIdsChange])
const handleToggleSmartSelect = useCallback(
(assetId: string) => {
onSmartSelectedIdsChange(
smartSelectedIds.includes(assetId)
? smartSelectedIds.filter((id) => id !== assetId)
: [...smartSelectedIds, assetId],
)
},
[smartSelectedIds, onSmartSelectedIdsChange],
)
/* ── 计算已选素材总时长 ── */
const smartSelectedTotalDuration = useMemo(
() =>
materials.items
.filter((a) => smartSelectedIds.includes(a.id))
.reduce((sum, a) => sum + (a.duration || 0), 0),
[materials.items, smartSelectedIds],
)
return {
smartMatching,
hasMatched,
smartSelectedIds,
smartMatchedResults,
handleSmartMatch,
handleToggleSmartSelect,
handleRefreshMatch,
handleSelectAllMatched,
handleClearSmartSelect,
smartSelectedTotalDuration,
}
}
@@ -15,12 +15,14 @@ import type { AxiosResponse } from "axios"
* 使用 Promise.allSettled 确保单个失败不影响整体
*/
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
if (!ids.length) return []
// 防御:过滤空值/undefined/非字符串 id,避免发出 /assets/undefined 请求
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
if (!validIds.length) return []
try {
const { default: apiClient } = await import("@/api/client")
const results = await Promise.allSettled(
ids.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
validIds.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
)
return results
.filter(
@@ -57,7 +59,10 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
const stableAssetIds = useStableArray(assetIds)
const load = useCallback(async () => {
if (!stableAssetIds.length || !enabled) {
const validIds = stableAssetIds.filter(
(id): id is string => typeof id === "string" && id.length > 0,
)
if (!validIds.length || !enabled) {
setAssets([])
setReady(false)
return
@@ -68,7 +73,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
setReady(false)
try {
const result = await fetchAssetsByIds(stableAssetIds)
const result = await fetchAssetsByIds(validIds)
// 防止竞态:只保留最新请求的结果
if (requestIdRef.current === thisRequestId) {
setAssets(result)
@@ -7,7 +7,6 @@ import { message } from "antd"
import type { TemplateSegment } from "@/api/templates/types"
import type { EditPlanClip } from "@/api/template-editor"
import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/api/template-editor"
import { formatDuration } from "../utils/formatDuration"
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
import { useDraftAutoSave } from "./useDraftAutoSave"
@@ -38,14 +37,21 @@ export function useStep2Materials({
templateSegments,
onServerClipsChange,
}: UseStep2MaterialsProps) {
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
useMaterialLibrary()
const {
libraries,
selectedLibraryId,
setSelectedLibraryId,
materials,
selectableMaterials,
allMaterialsExhausted,
materialsLoading,
} = useMaterialLibrary()
const smartMatch = useSmartMatch({
libraryId: selectedLibraryId,
materials,
smartSelectedIds,
materials: selectableMaterials,
onSmartSelectedIdsChange,
templateSegments,
})
/* ── 自动触发智能匹配:选择视频库后自动调用 ── */
@@ -59,13 +65,19 @@ export function useStep2Materials({
}
if (!selectedLibraryId) return
if (materialsLoading) return
if (materials.items.length === 0) return
if (selectableMaterials.items.length === 0) return
// 防止同一视频库重复触发
if (autoTriggeredRef.current === selectedLibraryId) return
autoTriggeredRef.current = selectedLibraryId
handleSmartMatch()
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
}, [
selectedLibraryId,
materialMode,
materialsLoading,
selectableMaterials.items,
handleSmartMatch,
])
/* ── Step2 选择素材后自动保存草稿 asset_ids(防抖 500ms,失败静默) ── */
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
@@ -169,6 +181,8 @@ export function useStep2Materials({
selectedLibraryId,
setSelectedLibraryId,
materials,
selectableMaterials,
allMaterialsExhausted,
materialsLoading,
// 模式
materialMode,
@@ -176,19 +190,11 @@ export function useStep2Materials({
// 手动选择
selectedMaterials,
handleToggleMaterial,
// 智能匹配(简化版)
// 智能匹配
smartMatching: smartMatch.smartMatching,
hasMatched: smartMatch.hasMatched,
smartSelectedIds: smartMatch.smartSelectedIds,
smartMatchedResults: smartMatch.smartMatchedResults,
handleSmartMatch: smartMatch.handleSmartMatch,
handleToggleSmartSelect: smartMatch.handleToggleSmartSelect,
handleRefreshMatch: smartMatch.handleRefreshMatch,
handleSelectAllMatched: smartMatch.handleSelectAllMatched,
handleClearSmartSelect: smartMatch.handleClearSmartSelect,
smartSelectedTotalDuration: smartMatch.smartSelectedTotalDuration,
// utils
formatDuration,
}
}
+4 -23
View File
@@ -1,6 +1,6 @@
/**
* 成片库页面 — V21 设计系统
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选
*
* 主组件仅保留 Hook 组装与整体布局
* 列表查询 → hooks/useProductList
@@ -8,15 +8,12 @@
* 筛选栏 → components/ProductFilterBar
* 批量操作栏 → components/ProductBatchBar
* 空状态 → components/ProductEmptyState
* 产品卡片 → components/ProductCard
* 视频播放 → components/VideoPlayer
* 产品卡片 → components/ProductCard(内联视频播放)
*/
import React, { useState } from "react"
import React from "react"
import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { ProductItem } from "./types"
import { ProductCard } from "./components/ProductCard"
import { VideoPlayer } from "./components/VideoPlayer"
import { ProductFilterBar } from "./components/ProductFilterBar"
import { ProductBatchBar } from "./components/ProductBatchBar"
import { ProductEmptyState } from "./components/ProductEmptyState"
@@ -53,13 +50,9 @@ const ProductLibrary: React.FC = () => {
clearSelection,
} = useProductList()
/* 播放器 */
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(null)
const {
handleDownload,
handleShare,
handleViewDetail,
handleDelete,
handlePublish,
handleReviewStatusChange,
@@ -71,7 +64,7 @@ const ProductLibrary: React.FC = () => {
selectedIds,
clearSelection,
products,
setPlayingProduct,
setPlayingProduct: () => {}, // 不再使用弹窗播放
})
// ── Loading 状态 ──
@@ -146,7 +139,6 @@ const ProductLibrary: React.FC = () => {
isSelected={selectedIds.has(product.id)}
batchMode={batchMode}
onToggleSelect={handleToggleSelect}
onPlay={setPlayingProduct}
onDownload={handleDownload}
onShare={handleShare}
onDelete={handleDelete}
@@ -158,17 +150,6 @@ const ProductLibrary: React.FC = () => {
) : (
<ProductEmptyState type="empty" />
)}
{/* 视频播放弹窗 */}
{playingProduct && (
<VideoPlayer
product={playingProduct}
onClose={() => setPlayingProduct(null)}
onDownload={handleDownload}
onShare={handleShare}
onViewDetail={handleViewDetail}
/>
)}
</div>
)
}
@@ -1,4 +1,4 @@
import React from "react"
import React, { useState, useRef, useCallback, useEffect } from "react"
import { Popconfirm } from "antd"
import {
CheckOutlined,
@@ -7,6 +7,7 @@ import {
ShareAltOutlined,
DeleteOutlined,
CloudUploadOutlined,
VideoCameraOutlined,
} from "@ant-design/icons"
import type { ProductItem } from "../types"
import { statusConfig, reviewStatusConfig } from "../constants"
@@ -17,7 +18,6 @@ interface ProductCardProps {
isSelected: boolean
batchMode: boolean
onToggleSelect: (id: string) => void
onPlay: (product: ProductItem) => void
onDownload: (product: ProductItem) => void
onShare: (product: ProductItem) => void
onDelete: (id: string) => void
@@ -30,7 +30,6 @@ export const ProductCard: React.FC<ProductCardProps> = ({
isSelected,
batchMode,
onToggleSelect,
onPlay,
onDownload,
onShare,
onDelete,
@@ -38,17 +37,39 @@ export const ProductCard: React.FC<ProductCardProps> = ({
onReviewStatusChange,
}) => {
const st = statusConfig[product.status]
const videoRef = useRef<HTMLVideoElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [aspectRatio, setAspectRatio] = useState<string | null>(null)
const [hasVideo, setHasVideo] = useState(!!product.videoUrl)
/** 查重率样式 */
const dupClass =
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
/** 点击卡片 */
const handleCardClick = () => {
if (batchMode) {
onToggleSelect(product.id)
/** 视频元数据加载后获取实际比例 */
const handleVideoLoaded = useCallback(() => {
const v = videoRef.current
if (v && v.videoWidth > 0 && v.videoHeight > 0) {
setAspectRatio(`${v.videoWidth} / ${v.videoHeight}`)
}
}, [])
/** 视频播放/暂停结束事件 */
const handlePlay = useCallback(() => setIsPlaying(true), [])
const handlePause = useCallback(() => setIsPlaying(false), [])
/** 视频出错时降级为缩略图 */
const handleVideoError = useCallback(() => setHasVideo(false), [])
/** 点击缩略图区域:有视频则内联播放,否则不响应 */
const handleThumbClick = () => {
if (batchMode) return
if (!hasVideo || !videoRef.current) return
const v = videoRef.current
if (v.paused) {
v.play()
} else {
onPlay(product)
v.pause()
}
}
@@ -58,6 +79,24 @@ export const ProductCard: React.FC<ProductCardProps> = ({
onToggleSelect(product.id)
}
/** 卡片容器点击(非批量模式下不再触发弹窗播放) */
const handleCardClick = () => {
if (batchMode) {
onToggleSelect(product.id)
}
}
/** 组件卸载时暂停视频 */
useEffect(() => {
const v = videoRef.current
return () => {
v?.pause()
}
}, [])
/** 缩略图区域 style:有实际比例则用实际比例,否则默认 16:9 */
const thumbStyle: React.CSSProperties = aspectRatio ? { aspectRatio } : { aspectRatio: "16 / 9" }
return (
<div
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
@@ -102,21 +141,61 @@ export const ProductCard: React.FC<ProductCardProps> = ({
</div>
)}
{/* 缩略图 */}
<div className="xx-product-thumb">
{product.thumbnailUrl ? (
<img
className="xx-product-thumb-bg"
src={product.thumbnailUrl}
alt={product.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
{/* 视频/缩略图区域 */}
<div className="xx-product-thumb" style={thumbStyle}>
{hasVideo ? (
<>
<video
ref={videoRef}
className="xx-product-thumb-video"
src={product.videoUrl}
preload="metadata"
onLoadedMetadata={handleVideoLoaded}
onPlay={handlePlay}
onPause={handlePause}
onEnded={handlePause}
onError={handleVideoError}
controls={isPlaying}
onClick={(e) => {
e.stopPropagation()
handleThumbClick()
}}
/>
{/* 未播放时显示播放按钮覆盖层 */}
{!isPlaying && (
<div className="xx-product-play" onClick={handleThumbClick}>
<PlayCircleOutlined />
</div>
)}
</>
) : (
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
<>
{product.thumbnailUrl ? (
<img
className="xx-product-thumb-bg"
src={product.thumbnailUrl}
alt={product.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<div
className="xx-product-thumb-bg"
style={{
background: product.gradient,
display: "grid",
placeItems: "center",
color: "rgba(255,255,255,0.3)",
fontSize: "48px",
}}
>
<VideoCameraOutlined />
</div>
)}
<div className="xx-product-play">
<PlayCircleOutlined />
</div>
</>
)}
<div className="xx-product-play">
<PlayCircleOutlined />
</div>
{product.duration > 0 && (
<span className="xx-product-duration">{formatTime(product.duration)}</span>
)}
+14 -2
View File
@@ -260,13 +260,13 @@
/* 缩略图区域 */
.xx-product-thumb {
aspect-ratio: 9 / 16;
max-height: 220px;
position: relative;
overflow: hidden;
display: grid;
place-items: center;
color: var(--text-inverse);
background: var(--color-gray-950);
max-height: 320px;
}
.xx-product-thumb-bg {
@@ -276,6 +276,18 @@
background-position: center;
}
/* 内联视频播放器 */
.xx-product-thumb-video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.xx-product-thumb-video[controls] {
cursor: pointer;
}
.xx-product-play {
position: relative;
z-index: 1;
@@ -102,6 +102,7 @@ const VoiceMaterialLibrary: React.FC = () => {
ttsAudioUrl,
ttsError,
presetVoices,
clonedVoices,
setTtsOpen,
setTtsText,
setTtsVoiceId,
@@ -318,6 +319,7 @@ const VoiceMaterialLibrary: React.FC = () => {
audioUrl={ttsAudioUrl ?? ""}
error={ttsError ?? ""}
presetVoices={presetVoices}
clonedVoices={clonedVoices}
onClose={handleTtsClose}
onTextChange={setTtsText}
onVoiceChange={setTtsVoiceId}
@@ -9,6 +9,12 @@ export interface TtsPresetVoice {
name: string
}
/** 克隆音色下拉选项(仅 ready) */
export interface TtsClonedVoice {
id: string
name: string
}
interface TtsModalProps {
open: boolean
text: string
@@ -18,6 +24,8 @@ interface TtsModalProps {
audioUrl: string
error: string
presetVoices: TtsPresetVoice[]
/** 可用克隆音色(仅 ready),为空时不显示该分组 */
clonedVoices?: TtsClonedVoice[]
onClose: () => void
onTextChange: (value: string) => void
onVoiceChange: (voiceId: string) => void
@@ -35,6 +43,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
audioUrl,
error,
presetVoices,
clonedVoices = [],
onClose: _onClose,
onTextChange,
onVoiceChange,
@@ -101,11 +110,22 @@ const TtsModal: React.FC<TtsModalProps> = ({
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.voice_id} value={v.voice_id}>
{v.name}
</option>
))}
<optgroup label="预置音色">
{presetVoices.map((v) => (
<option key={v.voice_id} value={v.voice_id}>
{v.name}
</option>
))}
</optgroup>
{clonedVoices.length > 0 && (
<optgroup label="我的克隆音色">
{clonedVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</optgroup>
)}
</select>
</div>
@@ -54,7 +54,8 @@ const CardPlayer: React.FC<CardPlayerProps> = ({
document.addEventListener("mouseup", handleUp)
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
// 仅播放中的卡片显示进度,避免页面级 currentTime 联动所有卡片
const progress = isPlaying && duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="vmat-card-player">
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices"
import { getVoiceClonesWithTotal, toVoiceClone } from "@/api/voice-clone"
/**
* TTS 合成 Hook
@@ -31,6 +32,17 @@ export function useTtsSynthesize() {
})
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []
// 我的克隆音色(仅 ready 可用于合成;voice_id 直接传 profile UUID,后端解析)
const { data: clonedData } = useQuery({
queryKey: ["voice-clones"],
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
staleTime: 60_000,
})
const clonedVoices = (clonedData?.items ?? [])
.map((p) => toVoiceClone(p))
.filter((c) => c.status === "ready")
.map((c) => ({ id: c.id, name: c.name }))
/** 开始 AI 配音合成 */
const handleTtsSynthesize = useCallback(async () => {
if (!ttsText.trim()) {
@@ -124,6 +136,7 @@ export function useTtsSynthesize() {
ttsAudioUrl,
ttsError,
presetVoices,
clonedVoices,
setTtsOpen,
setTtsText,
setTtsVoiceId,
@@ -48,20 +48,32 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
}
// 2. 上传文件(带进度,后端自动创建 ingest job)
const { ingest_job_id } = await uploadAssetDirect({
const complete = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
// 3. 轮询 ingest job 状态
// 去重命中(同库已存在相同 file_hash 素材):
// 后端返回 duplicated=trueingest_job_id 为空;跳过轮询和打标签,
// mutationFn 正常 return 即 resolveuseMutation 自动触发 onSuccess 刷新列表。
// 已存在素材复用上次标签,无需重新打标。
if (complete.duplicated === true) {
return
}
if (!complete.ingest_job_id) {
throw new Error("上传完成但未返回处理任务 ID,请重试")
}
const { ingest_job_id } = complete
// 3. 轮询 ingest job 状态(complete 后先立即查一次,未完成再每 5s 轮询)
let job: Awaited<ReturnType<typeof getIngestJob>> | null = null
let retries = 0
const maxRetries = 60 // 最多等待 5 分钟
while (retries < maxRetries) {
job = await getIngestJob(ingest_job_id)
while (job.status !== "completed" && job.status !== "failed" && retries < maxRetries) {
await new Promise((r) => setTimeout(r, 5000))
job = await getIngestJob(ingest_job_id)
if (job.status === "completed" || job.status === "failed") break
retries++
}
@@ -72,7 +84,7 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
throw new Error("音频处理超时,请稍后在素材库查看")
}
// 4. 打标签(标签走独立 API)
// 4. 打标签(标签走独立 API;去重命中时已提前 return,这里只对新创建的素材执行
if (data.tagIds.length > 0 && job.result_asset_id) {
await tagAsset(job.result_asset_id, data.tagIds)
}
+55 -12
View File
@@ -33,6 +33,7 @@ import { useCloneOperations } from "./hooks/useCloneOperations"
import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
import { useVoiceUpload } from "./hooks/useVoiceUpload"
import { useMaterialDelete } from "./hooks/useMaterialDelete"
import { useMaterialBatchDelete } from "./hooks/useMaterialBatchDelete"
import "./voices.css"
let toastIdSeq = 0
@@ -71,23 +72,39 @@ const VoiceLibrary: React.FC = () => {
materialCount,
} = useVoicesData()
// ── 配音素材删除 ──────────────────────────────────────
const { handleMaterialDelete } = useMaterialDelete({
materials: materials as AssetItem[],
showToast,
})
// ── 播放控制 ──────────────────────────────────────────
// ── 播放控制(三 tab 共用:真实 Audio 播放 + TTS 试听) ──
const {
playingId,
loadingId,
currentTime,
handlePlay,
duration: playDuration,
handlePause,
handleToggleVoice,
handleToggleMaterial,
handleSeek,
handleTogglePlay,
stopPlayback,
} = useAudioPlayer()
// ── 配音素材删除 / 批量删除(删除正在播放的素材时停止播放) ──
const { handleMaterialDelete } = useMaterialDelete({
materials: materials as AssetItem[],
stopPlayback,
showToast,
})
const {
selectedIds: materialSelectedIds,
selectedCount: materialSelectedCount,
allSelected: materialAllSelected,
batchDeleting: materialBatchDeleting,
toggleSelect: toggleMaterialSelect,
toggleSelectAll: toggleMaterialSelectAll,
handleBatchDelete: handleMaterialBatchDelete,
} = useMaterialBatchDelete({
materials: materials as AssetItem[],
stopPlayback,
showToast,
})
// ── 克隆音色操作 ──────────────────────────────────────
const {
detailVoice,
@@ -117,7 +134,13 @@ const VoiceLibrary: React.FC = () => {
handleTtsSynthesize,
handleTtsSave,
handleTtsClose,
} = useTtsSynthesize({ presetVoices, showToast })
} = useTtsSynthesize({
presetVoices,
clonedVoices: clonedVoices
.filter((v) => v.status === "ready")
.map((v) => ({ id: v.id, name: v.name })),
showToast,
})
// ── 上传音频 ──────────────────────────────────────────
const {
@@ -210,8 +233,10 @@ const VoiceLibrary: React.FC = () => {
loading={presetLoading}
voices={filteredPreset}
playingId={playingId}
loadingId={loadingId}
currentTime={currentTime}
onPlay={handlePlay}
playDuration={playDuration}
onToggle={handleToggleVoice}
onPause={handlePause}
onSeek={handleSeek}
onClearFilters={handleClearFilters}
@@ -224,9 +249,12 @@ const VoiceLibrary: React.FC = () => {
loading={cloneLoading}
voices={clonedVoices}
playingId={playingId}
loadingId={loadingId}
currentTime={currentTime}
onPlay={handleTogglePlay}
playDuration={playDuration}
onToggle={handleToggleVoice}
onPause={handlePause}
onSeek={handleSeek}
onUse={handleCloneUse}
onDelete={handleCloneDelete}
onRetry={handleCloneRetry}
@@ -242,6 +270,18 @@ const VoiceLibrary: React.FC = () => {
materials={materials as AssetItem[]}
onOpenUpload={() => setUploadOpen(true)}
onDelete={handleMaterialDelete}
selectedIds={materialSelectedIds}
selectedCount={materialSelectedCount}
allSelected={materialAllSelected}
batchDeleting={materialBatchDeleting}
onToggleSelect={toggleMaterialSelect}
onToggleSelectAll={toggleMaterialSelectAll}
onBatchDelete={handleMaterialBatchDelete}
playingId={playingId}
currentTime={currentTime}
playDuration={playDuration}
onTogglePlay={handleToggleMaterial}
onSeek={handleSeek}
/>
)}
@@ -274,6 +314,9 @@ const VoiceLibrary: React.FC = () => {
ttsAudioUrl={ttsAudioUrl}
ttsError={ttsError}
presetVoices={presetVoices}
clonedVoices={clonedVoices
.filter((v) => v.status === "ready")
.map((v) => ({ id: v.id, name: v.name }))}
onTtsClose={handleTtsClose}
onTtsTextChange={setTtsText}
onTtsVoiceChange={setTtsVoiceId}
@@ -8,9 +8,12 @@ import CardFooter from "./clone-voice-card/CardFooter"
export interface CloneVoiceCardProps {
voice: ClonedVoiceDisplay
isPlaying: boolean
isLoading?: boolean
currentTime: number
playDuration?: number
onPlay: () => void
onPause: () => void
onSeek?: (time: number) => void
onUse: () => void
onDelete: () => void
onRetry: () => void
@@ -12,9 +12,12 @@ export interface ClonedVoiceTabProps {
loading: boolean
voices: ClonedVoiceDisplay[]
playingId: string | null
loadingId: string | null
currentTime: number
onPlay: (voiceId: string, duration: number) => void
playDuration: number
onToggle: (voiceId: string) => void
onPause: () => void
onSeek: (time: number) => void
onUse: (voice: ClonedVoiceDisplay) => void
onDelete: (voice: ClonedVoiceDisplay) => void
onRetry: (voice: ClonedVoiceDisplay) => void
@@ -26,9 +29,12 @@ export const ClonedVoiceTab: React.FC<ClonedVoiceTabProps> = ({
loading,
voices,
playingId,
loadingId,
currentTime,
onPlay,
playDuration,
onToggle,
onPause,
onSeek,
onUse,
onDelete,
onRetry,
@@ -54,9 +60,12 @@ export const ClonedVoiceTab: React.FC<ClonedVoiceTabProps> = ({
key={voice.id}
voice={voice}
isPlaying={playingId === voice.id}
isLoading={loadingId === voice.id}
currentTime={playingId === voice.id ? currentTime : 0}
onPlay={() => onPlay(voice.id, voice.duration)}
playDuration={playingId === voice.id ? playDuration : voice.duration}
onPlay={() => onToggle(voice.voiceId)}
onPause={onPause}
onSeek={onSeek}
onUse={() => onUse(voice)}
onDelete={() => onDelete(voice)}
onRetry={() => onRetry(voice)}
@@ -1,37 +0,0 @@
import React from "react"
import { AudioOutlined } from "@ant-design/icons"
import { type AssetItem } from "@/api/assets"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface MaterialVoiceCardProps {
asset: AssetItem
onClick?: () => void
}
/** 配音素材卡片 */
const MaterialVoiceCard: React.FC<MaterialVoiceCardProps> = ({ asset, onClick }) => {
const duration = (asset.metadata?.duration as number) || 0
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return (
<div className="vmat-card" onClick={onClick}>
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
</div>
<div className="vmat-info">
<div className="vmat-name" title={asset.name}>
{asset.name}
</div>
<div className="vmat-meta">
<span>{asset.file_size ? formatFileSize(asset.file_size) : "--"}</span>
</div>
</div>
</div>
)
}
export default MaterialVoiceCard
@@ -1,16 +1,45 @@
/**
* VoiceLibrary 配音素材 Tab 内容
*
* 卡片视觉与预置音色卡片(xx-voice-card)一致:白底圆角横向布局、
* 左侧圆形图标、名称/副标题、波形条、底部播放控件。
* 删除按钮常驻右上角;支持全选/多选 + 批量删除。
* 播放状态由页面级统一 hookVoiceLibrary/useAudioPlayer)下发。
*/
import React from "react"
import { SoundOutlined, UploadOutlined, AudioOutlined, DeleteOutlined } from "@ant-design/icons"
import React, { useRef } from "react"
import {
SoundOutlined,
UploadOutlined,
AudioOutlined,
DeleteOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CheckOutlined,
} from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { AssetItem } from "@/api/assets"
import { mapAssetToMaterial, type VoiceMaterial } from "@/pages/voice-materials/types"
import { formatTime, formatFileSize } from "../utils/format"
export interface MaterialVoiceTabProps {
loading: boolean
materials: AssetItem[]
onOpenUpload: () => void
onDelete: (asset: AssetItem) => void
// 批量删除
selectedIds: Set<string>
selectedCount: number
allSelected: boolean
batchDeleting: boolean
onToggleSelect: (id: string) => void
onToggleSelectAll: () => void
onBatchDelete: () => void
// 播放控制(页面级统一 hook)
playingId: string | null
currentTime: number
playDuration: number
onTogglePlay: (material: VoiceMaterial) => void
onSeek: (time: number) => void
}
export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
@@ -18,16 +47,80 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
materials,
onOpenUpload,
onDelete,
selectedIds,
selectedCount,
allSelected,
batchDeleting,
onToggleSelect,
onToggleSelectAll,
onBatchDelete,
playingId,
currentTime,
playDuration,
onTogglePlay,
onSeek,
}) => {
const progressRefs = useRef<Record<string, HTMLDivElement | null>>({})
/** 播放中点击进度条 seek;非播放态点击则开始播放 */
const handleProgressClick =
(asset: AssetItem, material: VoiceMaterial, dur: number) =>
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
if (!asset.file_url) return
if (playingId !== asset.id) {
onTogglePlay(material)
return
}
const el = progressRefs.current[asset.id]
if (!el || dur <= 0) return
const rect = el.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
onSeek(percent * dur)
}
return (
<div className="xx-voices-tab-content">
{/* 批量操作栏 */}
{!loading && materials.length > 0 && (
<div className="vmat-batch-bar">
<div className="vmat-batch-bar-left">
<button
type="button"
className={`vmat-checkbox${allSelected ? " checked" : ""}`}
onClick={onToggleSelectAll}
aria-label={allSelected ? "取消全选" : "全选"}
>
{allSelected && <CheckOutlined />}
</button>
<button type="button" className="vmat-select-all" onClick={onToggleSelectAll}>
{allSelected ? "取消全选" : "全选"}
</button>
{selectedCount > 0 && (
<span className="vmat-batch-count"> {selectedCount} </span>
)}
</div>
<div className="vmat-batch-bar-right">
<Button
buttonType="danger"
buttonSize="sm"
icon={<DeleteOutlined />}
disabled={selectedCount === 0 || batchDeleting}
onClick={onBatchDelete}
>
{batchDeleting ? "删除中..." : "批量删除"}
</Button>
</div>
</div>
)}
{/* 骨架屏加载 */}
{loading && (
<div className="xx-voice-grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="vmat-card vmat-card--skeleton">
<div className="vmat-thumb" />
<div className="vmat-info">
<div key={i} className="xx-voice-card vmat-card--skeleton">
<div className="vmat-skeleton-avatar" />
<div className="xx-voice-info">
<div className="vmat-skeleton-line vmat-skeleton-title" />
<div className="vmat-skeleton-line" />
</div>
@@ -40,42 +133,96 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
{!loading && materials.length > 0 && (
<div className="xx-voice-grid">
{materials.map((asset: AssetItem) => {
const duration = (asset.metadata?.duration as number) || 0
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
const material = mapAssetToMaterial(asset)
// duration 优先取顶层(后端从 metadata 提取),兜底 metadata
const cardDuration = asset.duration || material.duration || 0
const isPlaying = playingId === asset.id
const isSelected = selectedIds.has(asset.id)
// 播放中以 audio 真实时长为准,未播放显示卡片时长
const effectiveDuration = isPlaying ? playDuration || cardDuration : cardDuration
// 仅播放中的卡片显示进度,避免页面级 currentTime 联动所有卡片
const progress =
isPlaying && effectiveDuration > 0 ? (currentTime / effectiveDuration) * 100 : 0
return (
<div key={asset.id} className="vmat-card">
{/* hover 操作区:删除 */}
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete(asset)
}}
title="删除"
aria-label="删除配音素材"
>
<DeleteOutlined />
</button>
<div
key={asset.id}
className={`xx-voice-card vmat-card${isSelected ? " selected" : ""}${
isPlaying ? " playing" : ""
}`}
>
{/* 左上角批量选择 checkbox */}
<button
type="button"
className={`vmat-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(asset.id)
}}
aria-label={isSelected ? "取消选择" : "选择素材"}
>
{isSelected && <CheckOutlined />}
</button>
{/* 右上角删除按钮(常驻可见) */}
<button
type="button"
className="vmat-card-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(asset)
}}
title="删除"
aria-label="删除配音素材"
>
<DeleteOutlined />
</button>
<div className="xx-voice-avatar vmat-avatar">
<AudioOutlined />
</div>
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
</div>
<div className="vmat-info">
<div className="vmat-name" title={asset.name}>
<div className="xx-voice-info vmat-info">
<div className="xx-voice-name" title={asset.name}>
{asset.name}
</div>
<div className="vmat-meta">
<span>
{asset.file_size ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` : "--"}
</span>
<div className="xx-voice-subtitle">
{asset.file_size ? `${formatFileSize(asset.file_size)}` : "--"}
{" · "}
{cardDuration > 0 ? formatTime(cardDuration) : "--:--"}
</div>
</div>
{/* 波形装饰条(与预置音色卡片一致) */}
<div className="xx-voice-wave" />
{/* 播放控制区:真实音频播放 */}
<div className="xx-voice-controls">
<button
type="button"
className="xx-voice-play-btn"
disabled={!asset.file_url}
onClick={(e) => {
e.stopPropagation()
onTogglePlay(material)
}}
title={asset.file_url ? (isPlaying ? "暂停" : "试听") : "暂无可播放音频"}
aria-label={isPlaying ? "暂停播放" : "播放音频"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div
ref={(el) => {
progressRefs.current[asset.id] = el
}}
className="xx-voice-progress"
onClick={handleProgressClick(asset, material, effectiveDuration)}
>
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
</div>
<span className="xx-voice-time">
{isPlaying ? formatTime(currentTime) : formatTime(cardDuration)}
</span>
</div>
</div>
)
})}
@@ -19,10 +19,12 @@ export interface PresetVoiceTabProps {
loading: boolean
voices: PresetVoiceDisplay[]
playingId: string | null
loadingId: string | null
currentTime: number
onPlay: (id: string, duration: number) => void
playDuration: number
onToggle: (voiceId: string) => void
onPause: () => void
onSeek: (id: string, time: number, duration: number) => void
onSeek: (time: number) => void
onClearFilters: () => void
}
@@ -36,8 +38,10 @@ export const PresetVoiceTab: React.FC<PresetVoiceTabProps> = ({
loading,
voices,
playingId,
loadingId,
currentTime,
onPlay,
playDuration,
onToggle,
onPause,
onSeek,
onClearFilters,
@@ -71,15 +75,16 @@ export const PresetVoiceTab: React.FC<PresetVoiceTabProps> = ({
name={voice.name}
subtitle={`${genderLabel(voice.gender)} · ${languageLabel(voice.language)} · ${voice.description}`}
tags={voice.tags}
duration={voice.duration}
duration={playingId === voice.id ? playDuration : voice.duration}
gender={voice.gender}
isPlaying={playingId === voice.id}
isLoading={loadingId === voice.id}
isSelected={false}
currentTime={playingId === voice.id ? currentTime : 0}
starred={voice.starred}
onPlay={() => onPlay(voice.id, voice.duration)}
onPlay={() => onToggle(voice.voiceId)}
onPause={onPause}
onSeek={(time) => onSeek(voice.id, time, voice.duration)}
onSeek={(time) => onSeek(time)}
onToggleStar={() => {}}
/>
))}
@@ -18,6 +18,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
ttsAudioUrl,
ttsError,
presetVoices,
clonedVoices,
onClose,
onTextChange,
onVoiceChange,
@@ -36,7 +37,12 @@ const TtsModal: React.FC<TtsModalProps> = ({
}}
>
<TextInputSection value={ttsText} onChange={onTextChange} />
<VoiceSelector value={ttsVoiceId} onChange={onVoiceChange} presetVoices={presetVoices} />
<VoiceSelector
value={ttsVoiceId}
onChange={onVoiceChange}
presetVoices={presetVoices}
clonedVoices={clonedVoices}
/>
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
{ttsError && <ErrorAlert error={ttsError} />}
@@ -4,6 +4,7 @@ import {
PlayCircleOutlined,
PauseCircleOutlined,
HeartOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import { type VoiceGender } from "@/pages/voices/types"
import { genderClass, formatTime } from "@/pages/voices/utils/format"
@@ -16,6 +17,7 @@ export interface VoiceCardProps {
duration: number
gender: VoiceGender
isPlaying: boolean
isLoading?: boolean
isSelected: boolean
currentTime: number
starred?: boolean
@@ -36,6 +38,7 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
duration,
gender,
isPlaying,
isLoading = false,
isSelected,
currentTime,
starred,
@@ -51,8 +54,12 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current || status !== "ready") return
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
onSeek(percent * duration)
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
if (isPlaying && duration > 0) {
onSeek(percent * duration)
} else if (!isPlaying && !isLoading) {
onPlay()
}
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
@@ -110,19 +117,27 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
<div className="xx-voice-controls">
<button
className="xx-voice-play-btn"
disabled={isLoading}
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
aria-label={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
{isLoading ? (
<LoadingOutlined spin />
) : isPlaying ? (
<PauseCircleOutlined />
) : (
<PlayCircleOutlined />
)}
</button>
<div ref={progressRef} className="xx-voice-progress" onClick={handleProgressClick}>
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
</div>
<span className="xx-voice-time">
{isPlaying ? formatTime(currentTime) : formatTime(duration)}
{isPlaying ? formatTime(currentTime) : duration > 0 ? formatTime(duration) : "试听"}
</span>
</div>
)}
@@ -5,6 +5,7 @@ import React from "react"
import type { ClonedVoiceDisplay, PresetVoiceDisplay } from "../types"
import type { VoiceClone } from "@/api/voice-clone"
import type { TtsStatus } from "./TtsModal"
import type { TtsClonedVoiceOption } from "./tts-modal/VoiceSelector"
import CloneModal from "@/components/voice/CloneModal"
import CloneDetailModal from "./CloneDetailModal"
import UploadVoiceModal from "./UploadVoiceModal"
@@ -45,6 +46,8 @@ export interface VoiceModalsProps {
ttsAudioUrl: string | null
ttsError: string | null
presetVoices: PresetVoiceDisplay[]
/** 可用克隆音色(仅 ready */
clonedVoices?: TtsClonedVoiceOption[]
onTtsClose: () => void
onTtsTextChange: (text: string) => void
onTtsVoiceChange: (id: string) => void
@@ -81,6 +84,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
ttsAudioUrl,
ttsError,
presetVoices,
clonedVoices,
onTtsClose,
onTtsTextChange,
onTtsVoiceChange,
@@ -129,6 +133,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
ttsAudioUrl={ttsAudioUrl}
ttsError={ttsError}
presetVoices={presetVoices}
clonedVoices={clonedVoices}
onClose={onTtsClose}
onTextChange={onTtsTextChange}
onVoiceChange={onTtsVoiceChange}
@@ -1,13 +1,21 @@
import React from "react"
import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined } from "@ant-design/icons"
import {
PlayCircleOutlined,
PauseCircleOutlined,
ReloadOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
interface CardFooterProps {
voice: ClonedVoiceDisplay
isPlaying: boolean
isLoading?: boolean
currentTime: number
playDuration?: number
onPlay: () => void
onPause: () => void
onSeek?: (time: number) => void
onUse: () => void
onRetry: () => void
}
@@ -15,14 +23,30 @@ interface CardFooterProps {
const CardFooter: React.FC<CardFooterProps> = ({
voice,
isPlaying,
isLoading = false,
currentTime,
playDuration,
onPlay,
onPause,
onSeek,
onUse,
onRetry,
}) => {
const isFailed = voice.status === "failed"
const isProcessing = voice.status === "processing"
// 播放中以 audio 真实时长为准
const effectiveDuration = isPlaying ? playDuration || voice.duration : voice.duration
/** 播放中点击进度条 seek;非播放态点击触发播放 */
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
if (isPlaying && effectiveDuration > 0) {
onSeek?.(percent * effectiveDuration)
} else if (!isLoading) {
onPlay()
}
}
return (
<div className="xx-clone-footer">
@@ -31,21 +55,30 @@ const CardFooter: React.FC<CardFooterProps> = ({
<button
type="button"
className="xx-clone-play-btn"
disabled={isLoading}
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
aria-label={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
{isLoading ? (
<LoadingOutlined spin />
) : isPlaying ? (
<PauseCircleOutlined />
) : (
<PlayCircleOutlined />
)}
</button>
<div className="xx-clone-progress">
<div className="xx-clone-progress" onClick={handleProgressClick}>
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
width:
isPlaying && effectiveDuration > 0
? `${Math.min((currentTime / effectiveDuration) * 100, 100)}%`
: "0%",
}}
/>
</div>
@@ -2,14 +2,28 @@ import React from "react"
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import { genderLabel } from "@/pages/voices/utils/format"
/** 克隆音色下拉选项(最小结构,新旧页面各自映射) */
export interface TtsClonedVoiceOption {
/** 克隆音色 profile id(合成时直接作为 voice_id 传后端解析) */
id: string
name: string
}
interface VoiceSelectorProps {
value: string
onChange: (voiceId: string) => void
presetVoices: PresetVoiceDisplay[]
/** 可用克隆音色(仅克隆完成/ready),为空时不显示「我的克隆音色」分组 */
clonedVoices?: TtsClonedVoiceOption[]
}
/** 音色选择下拉 */
const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVoices }) => {
/** 音色选择下拉:预置音色 + 我的克隆音色 */
const VoiceSelector: React.FC<VoiceSelectorProps> = ({
value,
onChange,
presetVoices,
clonedVoices = [],
}) => {
return (
<div>
<div
@@ -36,11 +50,22 @@ const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVo
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
<optgroup label="预置音色">
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</optgroup>
{clonedVoices.length > 0 && (
<optgroup label="我的克隆音色">
{clonedVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</optgroup>
)}
</select>
</div>
)
@@ -1,4 +1,5 @@
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import type { TtsClonedVoiceOption } from "./VoiceSelector"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -11,6 +12,8 @@ export interface TtsModalProps {
ttsAudioUrl: string | null
ttsError: string | null
presetVoices: PresetVoiceDisplay[]
/** 可用克隆音色(仅 ready),为空时下拉不显示该分组 */
clonedVoices?: TtsClonedVoiceOption[]
onClose: () => void
onTextChange: (text: string) => void
onVoiceChange: (voiceId: string) => void
+162 -64
View File
@@ -1,102 +1,200 @@
import { useState, useRef, useCallback, useEffect } from "react"
/**
* 音频播放控制 Hook
* 封装当前播放状态、播放/暂停/跳转控制,使用 setInterval 模拟进度更新
* (适用于预置音色/克隆音色卡片的播放按钮交互
* 配音库真实音频播放控制 Hook(预置音色 / 克隆音色 / 配音素材三个 tab 共用)
*
* - 配音素材:直接播放 asset.file_url(用户上传的真实音频
* - 预置/克隆音色:调 POST /tts/preview 合成本示例文案,拿到 audio_url 后真实播放,
* 合成结果按 voiceId 内存缓存,同一音色二次试听不重复合成
* - 全库同一时刻只有一个 Audio 在响:切卡片 / 切 tab / 离开页面自动停止
* - timeupdate 驱动进度条,loadedmetadata 取真实时长,ended 自动复位
*
* 注意:合成失败的错误提示由 apiClient 拦截器统一 toast(含后端
* 「音色克隆尚未完成,请稍后再试」文案),hook 内不重复提示。
*/
import { useState, useRef, useCallback, useEffect } from "react"
import { previewTts } from "@/api/tts"
import type { VoiceMaterial } from "@/pages/voice-materials/types"
/** 卡片试听统一示例文案 */
export const VOICE_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的配音效果,希望你喜欢。"
interface PreviewCacheEntry {
url: string
duration?: number
}
export function useAudioPlayer() {
const [playingId, setPlayingId] = useState<string | null>(null)
const [currentTime, setCurrentTime] = useState(0)
const intervalRef = useRef<number | null>(null)
const [duration, setDuration] = useState(0)
const [loadingId, setLoadingId] = useState<string | null>(null)
/** 开始播放指定音色(从 startTime 开始,默认从 0 开始) */
const handlePlay = useCallback(
(voiceId: string, duration: number, startTime: number = 0) => {
if (playingId === voiceId) return
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
setPlayingId(voiceId)
setCurrentTime(startTime)
intervalRef.current = window.setInterval(() => {
setCurrentTime((prev) => {
if (prev >= duration) {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
setPlayingId(null)
return 0
}
return prev + 0.1
})
}, 100)
},
[playingId],
)
const audioRef = useRef<HTMLAudioElement | null>(null)
const pausedRef = useRef<{ id: string; url: string; duration: number } | null>(null)
const previewCacheRef = useRef<Map<string, PreviewCacheEntry>>(new Map())
/** 试听合成请求序号:旧请求返回时丢弃,防止竞态 */
const reqSeqRef = useRef(0)
/** 暂停播放 */
const handlePause = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
/** 停止当前播放并复位状态 */
const stopPlayback = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
pausedRef.current = null
setPlayingId(null)
setLoadingId(null)
setCurrentTime(0)
setDuration(0)
}, [])
/** 跳转到指定时间 */
const handleSeek = useCallback(
(voiceId: string, time: number, duration: number) => {
if (playingId !== voiceId) {
// 不同音色:从指定时间开始播放
handlePlay(voiceId, duration, time)
} else {
// 同一音色:直接跳转
setCurrentTime(time)
/** 用指定 URL 创建 Audio 并播放 */
const startAudio = useCallback((id: string, url: string, knownDuration?: number) => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
const audio = new Audio(url)
audioRef.current = audio
audio.addEventListener("timeupdate", () => {
setCurrentTime(audio.currentTime)
})
audio.addEventListener("loadedmetadata", () => {
if (Number.isFinite(audio.duration) && audio.duration > 0) {
setDuration(audio.duration)
}
})
audio.addEventListener("ended", () => {
if (audioRef.current === audio) audioRef.current = null
pausedRef.current = null
setPlayingId(null)
setCurrentTime(0)
})
if (knownDuration && knownDuration > 0) setDuration(knownDuration)
setCurrentTime(0)
setPlayingId(id)
pausedRef.current = null
audio.play().catch(() => {
// 自动播放被拦截或 URL 失效:复位按钮,错误提示由拦截器/环境处理
if (audioRef.current === audio) audioRef.current = null
setPlayingId(null)
setLoadingId((cur) => (cur === id ? null : cur))
})
}, [])
/** 播放配音素材(file_url 直链) */
const playMaterial = useCallback(
(material: VoiceMaterial) => {
if (!material.fileUrl) return
startAudio(material.id, material.fileUrl, material.duration)
},
[playingId, handlePlay],
[startAudio],
)
/** 切换播放/暂停 */
const handleTogglePlay = useCallback(
(voiceId: string, duration: number) => {
/** 预置/克隆音色试听:先 TTS 合成(带缓存),再真实播放 */
const playVoice = useCallback(
async (voiceId: string) => {
// 暂停中恢复
if (pausedRef.current?.id === voiceId && audioRef.current) {
try {
await audioRef.current.play()
} catch {
return
}
setPlayingId(voiceId)
pausedRef.current = null
return
}
const cached = previewCacheRef.current.get(voiceId)
if (cached) {
startAudio(voiceId, cached.url, cached.duration)
return
}
const seq = ++reqSeqRef.current
setLoadingId(voiceId)
try {
const res = await previewTts({ text: VOICE_PREVIEW_TEXT, voice_id: voiceId, speed: 1.0 })
if (seq !== reqSeqRef.current) return // 已被更新的请求取代
previewCacheRef.current.set(voiceId, { url: res.audio_url, duration: res.duration })
setLoadingId(null)
startAudio(voiceId, res.audio_url, res.duration)
} catch {
if (seq !== reqSeqRef.current) return
// 错误文案(含「克隆尚未完成」)由 apiClient 拦截器统一 toast
setLoadingId(null)
}
},
[startAudio],
)
/** 暂停(记录暂停对象,供再次点击恢复) */
const handlePause = useCallback(() => {
const audio = audioRef.current
if (!audio) return
audio.pause()
pausedRef.current = { id: playingId ?? "", url: audio.src, duration }
setPlayingId(null)
}, [playingId, duration])
/** 音色卡片播放/暂停切换 */
const handleToggleVoice = useCallback(
(voiceId: string) => {
if (playingId === voiceId) {
handlePause()
} else {
handlePlay(voiceId, duration)
stopPlayback()
void playVoice(voiceId)
}
},
[playingId, handlePlay, handlePause],
[playingId, handlePause, stopPlayback, playVoice],
)
/** 停止所有播放(切换 Tab 时调用) */
const stopPlayback = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
setPlayingId(null)
setCurrentTime(0)
/** 素材卡片播放/暂停切换 */
const handleToggleMaterial = useCallback(
(material: VoiceMaterial) => {
if (playingId === material.id) {
handlePause()
} else {
stopPlayback()
playMaterial(material)
}
},
[playingId, handlePause, stopPlayback, playMaterial],
)
/** 进度条 seek(仅播放中有效;非播放态点击进度条由卡片改为触发播放) */
const handleSeek = useCallback((time: number) => {
const audio = audioRef.current
if (!audio) return
audio.currentTime = time
setCurrentTime(time)
}, [])
// 组件卸载时清理
// 组件卸载时清理音频
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
}
}, [])
return {
playingId,
loadingId,
currentTime,
handlePlay,
duration,
playMaterial,
playVoice,
handlePause,
handleToggleVoice,
handleToggleMaterial,
handleSeek,
handleTogglePlay,
stopPlayback,
}
}
@@ -0,0 +1,97 @@
/**
* 配音库「配音素材」Tab 批量删除逻辑
*
* 选择状态本地维护;批量删除循环调 deleteAsset(无批量接口),
* 单个失败不中断;删除项含正在播放的素材时停止播放。
*/
import { useState, useCallback, useMemo } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { Modal } from "@/components/ui"
import { deleteAsset, type AssetItem } from "@/api/assets"
import type { Toast } from "../components/VoiceToasts"
interface UseMaterialBatchDeleteOptions {
materials: AssetItem[]
/** 停止播放回调(删除正在播放的素材时调用) */
stopPlayback: () => void
showToast: (message: string, type: Toast["type"]) => void
}
export function useMaterialBatchDelete({
materials,
stopPlayback,
showToast,
}: UseMaterialBatchDeleteOptions) {
const queryClient = useQueryClient()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchDeleting, setBatchDeleting] = useState(false)
/** 只统计当前列表中仍然存在的选中项(删除后自动收敛) */
const validSelected = useMemo(
() => materials.filter((m) => selectedIds.has(m.id)),
[materials, selectedIds],
)
const selectedCount = validSelected.length
const allSelected = materials.length > 0 && selectedCount === materials.length
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const toggleSelectAll = useCallback(() => {
setSelectedIds(allSelected ? new Set() : new Set(materials.map((m) => m.id)))
}, [allSelected, materials])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
const handleBatchDelete = useCallback(() => {
const targets = validSelected
if (targets.length === 0) return
Modal.confirm({
title: "确认批量删除",
content: `确定删除选中的 ${targets.length} 个素材?删除后不可恢复。`,
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
setBatchDeleting(true)
let successCount = 0
for (const asset of targets) {
try {
await deleteAsset(asset.id)
successCount++
} catch {
/* 单个失败不中断 */
}
}
// 删除项含正在播放的素材(播放 id 与素材 id 一致)→ 停止播放
stopPlayback()
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
setSelectedIds(new Set())
setBatchDeleting(false)
if (successCount === targets.length) {
showToast(`已批量删除 ${successCount} 个素材`, "success")
} else {
showToast(`已批量删除 ${successCount}/${targets.length} 个素材,部分失败`, "error")
}
},
})
}, [validSelected, stopPlayback, queryClient, showToast])
return {
selectedIds,
selectedCount,
allSelected,
batchDeleting,
toggleSelect,
toggleSelectAll,
clearSelection,
handleBatchDelete,
}
}
@@ -1,5 +1,5 @@
/**
* 配音库「配音素材」Tab 删除逻辑
* 配音库「配音素材」Tab 单个删除逻辑
*/
import { useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
@@ -9,10 +9,16 @@ import type { Toast } from "../components/VoiceToasts"
interface UseMaterialDeleteOptions {
materials: AssetItem[]
/** 停止播放回调(删除正在播放的素材时调用) */
stopPlayback: () => void
showToast: (message: string, type: Toast["type"]) => void
}
export function useMaterialDelete({ materials, showToast }: UseMaterialDeleteOptions) {
export function useMaterialDelete({
materials,
stopPlayback,
showToast,
}: UseMaterialDeleteOptions) {
const queryClient = useQueryClient()
const deleteMutation = useMutation({
@@ -20,6 +26,8 @@ export function useMaterialDelete({ materials, showToast }: UseMaterialDeleteOpt
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
// 删除成功后停止播放(若删的是正在播放的素材,播放 id 与素材 id 一致)
stopPlayback()
showToast("素材已删除", "success")
},
onError: (err: unknown) => {
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { type PresetVoiceDisplay } from "../types"
import type { TtsClonedVoiceOption } from "../components/tts-modal/VoiceSelector"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -12,10 +13,16 @@ export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
*/
interface UseTtsSynthesizeProps {
presetVoices: PresetVoiceDisplay[]
/** 可用克隆音色(仅 ready;合成时 voice_id 直接传克隆 profile UUID,后端解析) */
clonedVoices?: TtsClonedVoiceOption[]
showToast: (message: string, type: "success" | "error") => void
}
export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizeProps) {
export function useTtsSynthesize({
presetVoices,
clonedVoices = [],
showToast,
}: UseTtsSynthesizeProps) {
const queryClient = useQueryClient()
const [ttsOpen, setTtsOpen] = useState(false)
@@ -131,6 +138,7 @@ export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizePr
ttsError,
// 可选音色列表
ttsPresetVoices: presetVoices,
ttsClonedVoices: clonedVoices,
// Setters
setTtsText,
setTtsVoiceId,
@@ -32,24 +32,39 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
/* 直传文件(后端会自动创建 ingest job */
const { ingest_job_id } = await uploadAssetDirect({
const complete = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
/* 轮询 ingest job 状态,等待 Worker 处理完成 */
let jobStatus = ""
/* 去重命中(同库已存在相同 file_hash 素材):
* 后端返回 duplicated=trueingest_job_id 为空,
* 不轮询、直接按上传成功处理(onSuccess 分支 toast + 刷新列表)。
* 注意:mutationFn 正常 return 即视为 resolveuseMutation 会自动调 onSuccess。
*/
if (complete.duplicated === true) {
return
}
if (!complete.ingest_job_id) {
throw new Error("上传完成但未返回处理任务 ID,请重试")
}
const { ingest_job_id } = complete
/* 轮询 ingest job 状态,等待 Worker 处理完成;
* complete 后先立即查一次(后端通常不到 1s 处理完),未完成再每 5s 轮询。
* 成功状态为 IngestJobStatus.completed"ready" 是 voice-clones 的状态,此处误用需避免。
*/
let job = await getIngestJob(ingest_job_id)
let retries = 0
const maxRetries = 60 // 最多等待 5 分钟(60 * 5秒)
while (jobStatus !== "ready" && jobStatus !== "failed" && retries < maxRetries) {
while (job.status !== "completed" && job.status !== "failed" && retries < maxRetries) {
await new Promise((r) => setTimeout(r, 5000))
const job = await getIngestJob(ingest_job_id)
jobStatus = job.status
job = await getIngestJob(ingest_job_id)
retries++
}
if (jobStatus === "failed") {
if (job.status === "failed") {
throw new Error("音频处理失败,请重试")
}
if (retries >= maxRetries) {
+119 -76
View File
@@ -972,130 +972,173 @@
}
/* ================================================================
配音素材卡片(与配音库Tab集成
配音素材卡片(复用预置音色卡片 .xx-voice-card 布局与播放控件
================================================================ */
.vmat-card {
position: relative;
background: var(--bg-primary);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--border-color);
transition: all 0.2s;
cursor: pointer;
/* 圆形图标:紫色系(素材统一配色,不用预置音色的性别色) */
.vmat-card .vmat-avatar {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
/* 卡片 hover 操作按钮 */
.vmat-card-actions {
/* 左上角批量选择 checkbox */
.vmat-card-checkbox {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
display: flex;
gap: 4px;
top: 10px;
left: 10px;
z-index: 3;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border-color);
background: var(--bg-primary);
color: #fff;
display: grid;
place-items: center;
font-size: 11px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast);
transition: var(--transition-fast);
}
.vmat-card:hover .vmat-card-actions {
.vmat-card:hover .vmat-card-checkbox,
.vmat-card-checkbox.checked {
opacity: 1;
}
/* 触摸屏设备无 hover:操作按钮常驻显示 */
@media (hover: none) {
.vmat-card-actions {
opacity: 1;
}
.vmat-card-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
.vmat-card-action-btn {
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
/* 触摸屏无 hovercheckbox 常驻显示 */
@media (hover: none) {
.vmat-card-checkbox {
opacity: 1;
}
}
.vmat-card-action-btn {
display: flex;
align-items: center;
justify-content: center;
/* 右上角删除按钮:常驻可见,hover 加深为 danger 色 */
.vmat-card-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: var(--bg-hover);
color: var(--text-secondary);
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
font-size: 13px;
transition: all var(--transition-fast);
font-size: 14px;
display: grid;
place-items: center;
transition: var(--transition-fast);
}
.vmat-card-action-btn:hover {
background: var(--primary-50);
color: var(--primary-600);
}
.vmat-card-action-btn--danger:hover {
.vmat-card-delete:hover {
background: var(--error-50);
color: var(--error-600);
}
.vmat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
border-color: var(--primary-300);
/* 选中态高亮(覆盖预置卡片的选中边框) */
.vmat-card.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color);
}
.vmat-thumb {
position: relative;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
/* 信息区给 checkbox / 删除按钮留位 */
.vmat-card .vmat-info {
padding-right: 24px;
}
/* 播放波形/控件继承 .xx-voice-card 网格,无需额外样式 */
/* 无可播放音频时播放按钮置灰 */
.vmat-card .xx-voice-play-btn:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* ── 批量操作栏 ─────────────────────────────────── */
.vmat-batch-bar {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: var(--space-md);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
.vmat-thumb-icon {
font-size: 32px;
color: rgba(255, 255, 255, 0.9);
.vmat-batch-bar-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.vmat-duration {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.6);
.vmat-batch-bar-right {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.vmat-checkbox {
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border-color);
background: var(--bg-primary);
color: #fff;
font-size: 11px;
border-radius: 4px;
font-family: monospace;
display: grid;
place-items: center;
font-size: 10px;
line-height: 1;
cursor: pointer;
padding: 0;
transition: var(--transition-fast);
}
.vmat-info {
padding: 12px 14px;
.vmat-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
.vmat-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
.vmat-select-all {
background: none;
border: none;
padding: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
}
.vmat-meta {
font-size: 12px;
color: var(--text-tertiary);
.vmat-select-all:hover {
color: var(--primary-color);
}
/* 骨架屏 */
.vmat-batch-count {
font-size: var(--font-size-sm);
color: var(--primary-color);
font-weight: var(--font-weight-medium);
}
/* ── 骨架屏 ─────────────────────────────────────── */
.vmat-card--skeleton {
pointer-events: none;
}
.vmat-card--skeleton .vmat-thumb {
.vmat-skeleton-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
background: var(--bg-tertiary);
grid-row: 1 / 3;
}
.vmat-skeleton-line {
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest"
import { isAssetUsable } from "@/api/assets"
describe("isAssetUsable", () => {
it("usable 字段缺失时降级为可用(后端字段未上线零影响)", () => {
expect(isAssetUsable({})).toBe(true)
expect(isAssetUsable({ usable: undefined })).toBe(true)
expect(isAssetUsable({ usable: null })).toBe(true)
})
it("usable === true 时可用", () => {
expect(isAssetUsable({ usable: true })).toBe(true)
})
it("usable === false 时不可用(已彻底用尽)", () => {
expect(isAssetUsable({ usable: false })).toBe(false)
expect(isAssetUsable({ usable: false, used_ratio: 1 })).toBe(false)
})
it("used_ratio 不影响可用性判断(只影响角标展示)", () => {
expect(isAssetUsable({ used_ratio: 0.99 })).toBe(true)
expect(isAssetUsable({ used_ratio: 0 })).toBe(true)
})
})
@@ -1,42 +1,82 @@
import React from "react"
// 重构:useCloneModal Hook 已拆分为 useCloneFormState + useCloneSubmit 子 Hook
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import { render, screen } from "@testing-library/react"
import CloneModal from "@/components/voice/CloneModal"
vi.mock("react-router-dom", () => ({
useNavigate: () => vi.fn(),
}))
vi.mock("@/api/voice-clone", () => ({
createVoiceClone: vi.fn(),
toVoiceClone: vi.fn(),
toVoiceClone: vi.fn((x) => x),
}))
const mockGetAssetsByKind = vi.fn()
vi.mock("@/api/assets", () => ({
uploadAssetDirect: vi
.fn()
.mockResolvedValue({ storage_key: "test", ingest_job_id: "test", url: "http://test" }),
ensureDefaultLibrary: vi.fn().mockResolvedValue({ id: "lib-1" }),
getAssetsByKind: (...args: unknown[]) => mockGetAssetsByKind(...args),
}))
vi.mock("@/api/projects", () => ({
getOrCreateDefaultProject: vi.fn().mockResolvedValue({ id: "proj-1" }),
}))
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ enabled, queryFn }: { enabled: boolean; queryFn: () => unknown }) => {
// enabled=false 时不发请求(模拟弹窗关闭)
if (!enabled) return { data: undefined, isLoading: false }
return { data: mockQueryData, isLoading: mockLoading }
},
}))
let mockQueryData: unknown = undefined
let mockLoading = false
vi.mock("@/components/ui", () => ({
Modal: ({ open, children, onCancel, onOk, title }: any) =>
Modal: ({ open, children, onCancel, title }: any) =>
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
Button: ({ children, onClick, disabled, buttonType }: any) =>
React.createElement("button", { onClick, disabled, "data-type": buttonType }, children),
}))
describe("CloneModal", () => {
it("should render when closed", () => {
it("关闭时不渲染", () => {
mockQueryData = undefined
const { container } = render(<CloneModal open={false} onClose={vi.fn()} />)
expect(container).toBeTruthy()
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it("should render input phase when open", () => {
const { container } = render(<CloneModal open={true} onClose={vi.fn()} />)
expect(container).toBeTruthy()
it("打开时显示「从配音素材选择」和「直接录制」,不再有文件上传入口", () => {
mockQueryData = []
mockLoading = false
render(<CloneModal open={true} onClose={vi.fn()} />)
expect(screen.getByText("从配音素材选择")).toBeTruthy()
expect(screen.getByText("直接录制")).toBeTruthy()
// 文件上传入口已删除
expect(screen.queryByText(/拖拽音频文件/)).toBeNull()
expect(screen.queryByText("上传音频")).toBeNull()
})
it("should call onClose when cancel", () => {
const onClose = vi.fn()
render(<CloneModal open={true} onClose={onClose} />)
// just verify render doesn't crash
expect(onClose).toBeDefined()
it("素材为空时提示先上传素材并给跳转入口", () => {
mockQueryData = []
render(<CloneModal open={true} onClose={vi.fn()} />)
expect(screen.getByText("请先在配音库上传素材")).toBeTruthy()
expect(screen.getByText("去配音库上传")).toBeTruthy()
})
it("有素材时下拉展示素材名和时长", () => {
mockQueryData = [
{ id: "a1", name: "旁白录音.m4a", duration: 65 },
{ id: "a2", name: "访谈.mp3", duration: undefined },
]
render(<CloneModal open={true} onClose={vi.fn()} />)
expect(screen.getByText("旁白录音.m4a01:05")).toBeTruthy()
expect(screen.getByText("访谈.mp3--:--")).toBeTruthy()
// 空态提示不出现
expect(screen.queryByText("请先在配音库上传素材")).toBeNull()
})
})
@@ -1,11 +0,0 @@
/**
* Smoke test for utils
*/
import { describe, it, expect } from "vitest"
import "@/components/voice/CloneModal/utils"
describe("utils smoke", () => {
it("should load module successfully", () => {
expect(true).toBe(true)
})
})
@@ -20,7 +20,7 @@ import "@/pages/assets/components/CreateLibraryModal"
import "@/pages/assets/components/LibrarySidebar"
import "@/pages/assets/components/PlayModal"
import "@/pages/assets/components/ResultDrawer"
import "@/pages/assets/components/UploadProgressModal"
import "@/pages/assets/components/UploadQueuePanel"
// 类型与常量
import "@/pages/assets/types"
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest"
import { getUsageBadge } from "@/pages/assets/types"
describe("getUsageBadge", () => {
it("非视频素材不显示角标", () => {
expect(getUsageBadge({ kind: "voice", usable: false })).toBeNull()
expect(getUsageBadge({ kind: "image", usable: false })).toBeNull()
})
it("字段缺失时不显示角标(降级零影响)", () => {
expect(getUsageBadge({ kind: "video" })).toBeNull()
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: undefined })).toBeNull()
})
it("usable === false 显示红色实心「已用尽」", () => {
expect(getUsageBadge({ kind: "video", usable: false, usedRatio: 1 })).toEqual({
label: "已用尽",
variant: "exhausted",
})
// usable === false 优先级最高,即使 usedRatio 字段缺失
expect(getUsageBadge({ kind: "video", usable: false })).toEqual({
label: "已用尽",
variant: "exhausted",
})
})
it("used_ratio >= 0.85 显示红色「即将用尽」", () => {
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.85 })).toEqual({
label: "即将用尽",
variant: "warning",
})
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.97 })).toEqual({
label: "即将用尽",
variant: "warning",
})
})
it("used_ratio >= 0.5 显示橙色「已用 xx%」", () => {
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.5 })).toEqual({
label: "已用 50%",
variant: "ratio",
})
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.84 })).toEqual({
label: "已用 84%",
variant: "ratio",
})
})
it("used_ratio < 0.5 不显示角标", () => {
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.49 })).toBeNull()
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0 })).toBeNull()
})
})
@@ -0,0 +1,226 @@
/**
* useAssetUpload 队列批量上传测试
* - 并发直传不超过 MAX_CONCURRENT(3)
* - prepare 返回 asset_id 后 invalidate 列表
* - 状态流转 uploading→ingesting→done;失败可重试;duplicated 命中
*/
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
const invalidateQueries = vi.fn()
vi.mock("@/api/assets", () => ({
prepareDirectUploadHandle: vi.fn(),
}))
const { prepareDirectUploadHandle } = await import("@/api/assets")
const { useAssetUpload } = await import("@/pages/assets/hooks/useAssetUpload")
interface FakeHandle {
prepared: {
upload_url: string
method: string
storage_key: string
expires_at: string
fields: Record<string, string>
max_size_bytes: number
asset_id: string
}
transfer: ReturnType<typeof vi.fn>
complete: ReturnType<typeof vi.fn>
/** 手动结束传输(transfer 被调用后挂载);finish(true) 以失败结束 */
finish: (fail?: boolean) => void
}
let activeTransfers = 0
let maxConcurrent = 0
/**
* 创建一个假 handletransfer 返回挂起的 promise
* finish 槽位在 transfer executor 同步执行时挂载,测试中调用 finish() 控制成败
*/
const makeFakeHandle = (opts: { id: string; duplicated?: boolean; failTransfer?: boolean }) => {
const h = {
prepared: {
upload_url: "https://oss.example.com/u",
method: "POST",
storage_key: `uploads/${opts.id}/y.mp4`,
expires_at: "2099-01-01",
fields: {},
max_size_bytes: 2_000_000_000,
asset_id: opts.id,
},
transfer: vi.fn(),
complete: vi.fn().mockResolvedValue({
storage_key: "uploads/x/y.mp4",
ingest_job_id: opts.duplicated ? "" : "job-1",
url: "https://oss.example.com/u",
duplicated: opts.duplicated,
asset_id: opts.id,
}),
finish: (() => {}) as (fail?: boolean) => void,
}
h.transfer.mockImplementation(
() =>
new Promise<void>((_resolve, reject) => {
activeTransfers += 1
maxConcurrent = Math.max(maxConcurrent, activeTransfers)
h.finish = (fail = false) => {
activeTransfers -= 1
if (fail || opts.failTransfer) reject(new Error("OSS boom"))
else _resolve()
}
}),
)
return h
}
type FakeHandleLike = ReturnType<typeof makeFakeHandle>
/** prepare mock:调用序号生成稳定 id,立即把 handle(含 finish 槽位)推入数组 */
const installPrepareMock = (
handles: FakeHandleLike[],
optOverrides?: (id: string) => { duplicated?: boolean; failTransfer?: boolean },
) => {
let callNo = 0
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockImplementation(
async () => {
const id = `asset-${callNo++}`
const overrides = optOverrides?.(id) ?? {}
const h = makeFakeHandle({ id, ...overrides })
handles.push(h)
await new Promise((r) => setTimeout(r, 10))
return h
},
)
}
const createWrapper = () => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
// 监听 invalidate 调用
const orig = qc.invalidateQueries.bind(qc)
qc.invalidateQueries = ((...args: unknown[]) => {
invalidateQueries()
return orig(...(args as never))
}) as never
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
)
}
const mp4 = (name: string) => new File([new Uint8Array(10)], name, { type: "video/mp4" })
beforeEach(() => {
vi.clearAllMocks()
activeTransfers = 0
maxConcurrent = 0
})
describe("useAssetUpload", () => {
it("5 个文件批量入队:同时直传不超过 3 个,全部完成且刷新列表", async () => {
const handles: FakeHandleLike[] = []
installPrepareMock(handles)
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
wrapper: createWrapper(),
})
await act(async () => {
result.current.enqueueUploads(Array.from({ length: 5 }, (_, i) => mp4(`v${i}.mp4`)))
})
// 3 个进入 uploadingtransfer 被挂起),2 个排队
await waitFor(() => {
expect(handles.length).toBe(3)
expect(result.current.uploadItems.filter((it) => it.status === "uploading").length).toBe(3)
expect(result.current.uploadItems.filter((it) => it.status === "preparing").length).toBe(2)
})
expect(maxConcurrent).toBe(3)
// 完成前 3 个 → 队列拉起后 2 个
await act(async () => {
handles[0].finish()
handles[1].finish()
handles[2].finish()
})
await waitFor(() => {
expect(handles.length).toBe(5)
expect(result.current.uploadItems.filter((it) => it.status === "uploading").length).toBe(2)
})
expect(maxConcurrent).toBeLessThanOrEqual(3)
await waitFor(() => expect(handles[4].transfer).toHaveBeenCalled())
// 完成剩余 2 个
await act(async () => {
handles[3].finish()
handles[4].finish()
})
await waitFor(() => {
expect(result.current.uploadItems.filter((it) => it.status === "done").length).toBe(5)
})
expect(invalidateQueries).toHaveBeenCalled()
})
it("传输失败标记 error,重试后成功", async () => {
const handles: FakeHandleLike[] = []
let firstCall = true
installPrepareMock(handles, () => {
const fail = firstCall
firstCall = false
return { failTransfer: fail }
})
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
wrapper: createWrapper(),
})
await act(async () => {
result.current.enqueueUploads([mp4("bad.mp4")])
})
await waitFor(() => expect(handles.length).toBe(1))
await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled())
await act(async () => {
handles[0].finish()
})
await waitFor(() => expect(result.current.uploadItems[0].status).toBe("error"))
// 重试:重新 preparehandles[1] 成功)
const tempId = result.current.uploadItems[0].tempId
await act(async () => {
result.current.retryUpload(tempId)
})
await waitFor(() => expect(handles.length).toBe(2))
await waitFor(() => expect(handles[1].transfer).toHaveBeenCalled())
await act(async () => {
handles[1].finish()
})
await waitFor(() => {
expect(result.current.uploadItems.find((it) => it.tempId === tempId)?.status).toBe("done")
})
})
it("complete 返回 duplicated 时标记去重完成", async () => {
const handles: FakeHandleLike[] = []
installPrepareMock(handles, () => ({ duplicated: true }))
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
wrapper: createWrapper(),
})
await act(async () => {
result.current.enqueueUploads([mp4("dup.mp4")])
})
await waitFor(() => expect(handles.length).toBe(1))
await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled())
await act(async () => {
handles[0].finish()
})
await waitFor(() => {
expect(result.current.uploadItems[0].duplicated).toBe(true)
expect(result.current.uploadItems[0].status).toBe("done")
})
})
})
@@ -32,7 +32,6 @@ import "@/pages/generate/components/material/MaterialModeTabs"
import "@/pages/generate/components/material/ManualMaterialList"
import "@/pages/generate/components/material/SmartMatchInput"
import "@/pages/generate/components/material/SmartMatchResults"
import "@/pages/generate/components/material/SmartMatchCard"
import "@/pages/generate/components/title/AiTitleGenerator"
import "@/pages/generate/components/title/AiTitleCard"
import "@/pages/generate/components/title/TitleStylePanel"
@@ -1,232 +1,242 @@
/**
* useAudioPlayer hook 测试 — VoiceLibrary 版本
* useAudioPlayer hook 测试 — VoiceLibrary 版本(真实 Audio + TTS 试听)
*
* 该 Hook 使用 setInterval 模拟音频播放进度,纯逻辑可测
* 参考 voice-materials/hooks/useAudioPlayer.test.ts 的测试结构。
* mock HTMLAudioElement 与 previewTts,验证三 tab 共用的播放状态逻辑
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
import { renderHook, act } from "@testing-library/react"
import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
import type { VoiceMaterial } from "@/pages/voice-materials/types"
describe("useAudioPlayer (voices)", () => {
beforeEach(() => {
vi.useFakeTimers()
const mockAudioPlay = vi.fn()
const mockAudioPause = vi.fn()
const listeners: Record<string, (() => void) | ((ev: unknown) => void)> = {}
let mockAudioInstance: {
play: ReturnType<typeof vi.fn>
pause: ReturnType<typeof vi.fn>
addEventListener: (ev: string, cb: () => void) => void
currentTime: number
duration: number
src: string
volume: number
paused: boolean
}
const previewTtsMock = vi.fn()
vi.mock("@/api/tts", () => ({
previewTts: (...args: unknown[]) => previewTtsMock(...args),
}))
const mockMaterial: VoiceMaterial = {
id: "asset-1",
name: "测试素材",
description: "",
gender: "neutral",
tagIds: [],
fileName: "test.mp3",
fileSize: 1024,
duration: 30,
mimeType: "audio/mpeg",
createdAt: "2024-01-01T00:00:00Z",
fileUrl: "https://example.com/test.mp3",
}
beforeEach(() => {
vi.clearAllMocks()
for (const k of Object.keys(listeners)) delete listeners[k]
mockAudioInstance = {
play: mockAudioPlay.mockResolvedValue(undefined),
pause: mockAudioPause,
addEventListener: (ev: string, cb: () => void) => {
listeners[ev] = cb
},
currentTime: 0,
duration: 30,
src: "",
volume: 1,
paused: true,
}
global.Audio = vi.fn().mockImplementation((url: string) => {
mockAudioInstance.src = url
return mockAudioInstance
}) as unknown as typeof Audio
})
afterEach(() => {
vi.clearAllMocks()
})
describe("useAudioPlayer (voices, 真实播放)", () => {
it("初始状态为空", () => {
const { result } = renderHook(() => useAudioPlayer())
expect(result.current.playingId).toBeNull()
expect(result.current.loadingId).toBeNull()
expect(result.current.currentTime).toBe(0)
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it("应该使用初始状态初始化", () => {
it("playMaterial 用 file_url 真实播放素材", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.playMaterial(mockMaterial)
})
expect(result.current.playingId).toBe("asset-1")
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
expect(mockAudioInstance.src).toBe("https://example.com/test.mp3")
})
it("file_url 缺失时不播放", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.playMaterial({ ...mockMaterial, fileUrl: undefined })
})
expect(result.current.playingId).toBeNull()
expect(mockAudioPlay).not.toHaveBeenCalled()
})
it("handleToggleMaterial 播放中再点为暂停", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handleToggleMaterial(mockMaterial)
})
expect(result.current.playingId).toBe("asset-1")
act(() => {
result.current.handleToggleMaterial(mockMaterial)
})
expect(result.current.playingId).toBeNull()
expect(mockAudioPause).toHaveBeenCalled()
})
it("暂停后再次点击恢复播放", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handleToggleMaterial(mockMaterial)
})
act(() => {
result.current.handleToggleMaterial(mockMaterial) // 暂停
})
mockAudioPlay.mockClear()
act(() => {
result.current.handleToggleMaterial(mockMaterial) // 恢复
})
expect(result.current.playingId).toBe("asset-1")
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
})
it("playVoice 合成中显示 loading,成功后播放且二次点用缓存不重复合成", async () => {
previewTtsMock.mockResolvedValue({ audio_url: "https://example.com/tts.mp3", duration: 5 })
const { result } = renderHook(() => useAudioPlayer())
await act(async () => {
await result.current.playVoice("voice-1")
})
expect(previewTtsMock).toHaveBeenCalledWith(
expect.objectContaining({ voice_id: "voice-1", speed: 1.0 }),
)
expect(result.current.playingId).toBe("voice-1")
expect(mockAudioInstance.src).toBe("https://example.com/tts.mp3")
// 停止后再次试听同一音色 → 走缓存
act(() => {
result.current.stopPlayback()
})
mockAudioPlay.mockClear()
previewTtsMock.mockClear()
await act(async () => {
await result.current.playVoice("voice-1")
})
expect(previewTtsMock).not.toHaveBeenCalled()
expect(result.current.playingId).toBe("voice-1")
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
})
it("合成失败时复位 loading 且不播放", async () => {
previewTtsMock.mockRejectedValue(new Error("克隆尚未完成"))
const { result } = renderHook(() => useAudioPlayer())
await act(async () => {
await result.current.playVoice("voice-x")
})
expect(result.current.loadingId).toBeNull()
expect(result.current.playingId).toBeNull()
expect(mockAudioPlay).not.toHaveBeenCalled()
})
it("互斥:播放新素材时停止上一个", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.playMaterial(mockMaterial)
})
act(() => {
result.current.playMaterial({
...mockMaterial,
id: "asset-2",
fileUrl: "https://e.com/2.mp3",
})
})
expect(result.current.playingId).toBe("asset-2")
expect(mockAudioPause).toHaveBeenCalledTimes(1)
})
it("handleSeek 播放中调整 currentTime", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.playMaterial(mockMaterial)
})
act(() => {
result.current.handleSeek(12)
})
expect(mockAudioInstance.currentTime).toBe(12)
})
it("timeupdate 更新进度、ended 复位", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.playMaterial(mockMaterial)
})
act(() => {
mockAudioInstance.currentTime = 7
;(listeners["timeupdate"] as () => void)()
})
expect(result.current.currentTime).toBe(7)
act(() => {
;(listeners["ended"] as () => void)()
})
expect(result.current.playingId).toBeNull()
expect(result.current.currentTime).toBe(0)
})
it("handlePlay 应该开始播放指定音色", () => {
it("stopPlayback 复位全部状态", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
result.current.playMaterial(mockMaterial)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBe(0)
})
it("handlePlay 对同一个音色不应重复启动播放", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
const initialTime = result.current.currentTime
// 推进一些时间让进度走动
act(() => {
vi.advanceTimersByTime(200)
})
const timeAfterAdvance = result.current.currentTime
expect(timeAfterAdvance).toBeGreaterThan(initialTime)
// 对同一个音色再次调用 handlePlay 不应重置
act(() => {
result.current.handlePlay("voice-1", 10)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBe(timeAfterAdvance)
})
it("handlePlay 切换音色时应停止上一个并从头开始", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBeGreaterThan(0)
act(() => {
result.current.handlePlay("voice-2", 15)
})
expect(result.current.playingId).toBe("voice-2")
expect(result.current.currentTime).toBe(0)
})
it("播放进度应该随时间递增", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
// 每 100ms 增加 0.1
act(() => {
vi.advanceTimersByTime(300)
})
expect(result.current.currentTime).toBeCloseTo(0.3, 1)
expect(result.current.playingId).toBe("voice-1")
})
it("播放到结尾应自动停止并重置", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 0.5) // 0.5 秒的短音频
})
act(() => {
vi.advanceTimersByTime(600) // 超过 0.5 秒
})
expect(result.current.playingId).toBeNull()
expect(result.current.currentTime).toBe(0)
})
it("handlePause 应该暂停播放", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
vi.advanceTimersByTime(200)
})
const timeBeforePause = result.current.currentTime
act(() => {
result.current.handlePause()
})
expect(result.current.playingId).toBeNull()
// 暂停后时间不应再变化
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.currentTime).toBe(timeBeforePause)
})
it("handleSeek 应该跳转到指定时间", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
result.current.handleSeek("voice-1", 5, 10)
})
expect(result.current.currentTime).toBe(5)
})
it("handleSeek 对不同音色应该开始播放该音色", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
result.current.handleSeek("voice-2", 3, 15)
})
expect(result.current.playingId).toBe("voice-2")
expect(result.current.currentTime).toBe(3)
})
it("handleTogglePlay 应该在播放和暂停之间切换", () => {
const { result } = renderHook(() => useAudioPlayer())
// 初始为暂停,调用应开始播放
act(() => {
result.current.handleTogglePlay("voice-1", 10)
})
expect(result.current.playingId).toBe("voice-1")
// 再次调用应暂停
act(() => {
result.current.handleTogglePlay("voice-1", 10)
})
expect(result.current.playingId).toBeNull()
})
it("stopPlayback 应该重置所有播放状态", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
vi.advanceTimersByTime(300)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBeGreaterThan(0)
act(() => {
result.current.stopPlayback()
})
expect(result.current.playingId).toBeNull()
expect(result.current.currentTime).toBe(0)
// 停止后定时器不应再触发
const timeAfterStop = result.current.currentTime
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.currentTime).toBe(timeAfterStop)
})
it("返回值应该包含所有必要的方法和状态", () => {
const { result } = renderHook(() => useAudioPlayer())
expect(typeof result.current.handlePlay).toBe("function")
expect(typeof result.current.handlePause).toBe("function")
expect(typeof result.current.handleSeek).toBe("function")
expect(typeof result.current.handleTogglePlay).toBe("function")
expect(typeof result.current.stopPlayback).toBe("function")
expect(typeof result.current.playingId).toBe("object") // string | null
expect(typeof result.current.currentTime).toBe("number")
expect(mockAudioPause).toHaveBeenCalled()
})
})

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