Compare commits

..

10 Commits

Author SHA1 Message Date
xiaoxia 81e1eb47fb test(e2e): migrate to asset-libraries + /upload APIs (#1986)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
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 / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
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 / Build Staging Worker Image (push) Successful in 44s
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 API Image (push) Successful in 1m8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m0s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m22s
CI/CD Pipeline / Validate - Style (push) Successful in 4m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m59s
CI/CD Pipeline / Validate - Security (push) Successful in 9m19s
CI/CD Pipeline / Unit Tests (push) Successful in 9m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 10m0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12m26s
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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / PR Build API Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 14s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m11s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 16m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-19 02:11:46 +08:00
xiaoxia d3e4d6a07d feat: #1970 hflip 按 atom_clip ai_tags.has_text 放开 + 修复 develop migration 双头 (#1985)
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 / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (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 (push) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m13s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m48s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m38s
CI/CD Pipeline / Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m50s
CI/CD Pipeline / Validate - Style (push) Successful in 4m52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m52s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
AI Code Review / AI Code Review (pull_request) Successful in 7m14s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
2026-09-19 02:03:59 +08:00
xiaoxia 0d6ce433d0 fix: #1970 删除漏删的重复 migration 081_atom_clip_ai_tags(正确版已编号为 082) (#1984)
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build 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 6s
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 / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m8s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 4m10s
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 / 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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
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 - Python (mypy + alembic) (push) Successful in 4m44s
CI/CD Pipeline / Validate - Style (push) Successful in 4m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 56s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m57s
CI/CD Pipeline / Validate - Security (push) Successful in 11m55s
CI/CD Pipeline / Unit Tests (push) Successful in 12m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 26m13s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-19 01:20:23 +08:00
CI Bot eb2b009b33 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m9s
AI Code Review / AI Code Review (pull_request) Successful in 9m6s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Frontend 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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m57s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m58s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m14s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m5s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m56s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m58s
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 Production API Image (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 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 42s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 1m49s
2026-09-18 17:15:42 +00:00
xiaoxia fbd89b4089 feat: #1970 hflip 按 atom_clip ai_tags.has_text 放开 + 清理重复 081 migration
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m34s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 26s
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 / PR Build API Image (pull_request) Successful in 1m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m7s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 4m43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m58s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 11m18s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m37s
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 / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- render_adapter 按非 audio 源片段顺序批量查 atom_clip.ai_tags,
  仅 has_text 显式 false 标记无文字,其余(未打标签/true/null/查询失败)保守不翻转
- UnifiedRenderService 新增 clip_has_text 注入,None 维持 P1 全保守语义
- 删除残留 081_atom_clip_ai_tags.py(与 GPU PR 的 081 撞号,内容已由 082 承载),
  develop alembic 恢复单 head:080→081_add_gpu_lipsync→082_atom_clip_ai_tags
- 新增 19 个测试(纯函数混合标记/服务门控/适配器解析/失败回退),全量 15819 passed
2026-09-19 01:06:46 +08:00
xiaoxia 9b50e0696e test(e2e): 更新冒烟测试适配 #1970 智能剪辑新5步流程 (#1983)
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 (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / 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 / 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 / PR Build Worker Image (pull_request) Successful in 58s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m3s
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 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 / 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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 28s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m14s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 45s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 4m24s
CI/CD Pipeline / Validate - Style (push) Successful in 4m31s
CI/CD Pipeline / Integration Tests (push) Failing after 5m40s
AI Code Review / AI Code Review (pull_request) Successful in 6m37s
CI/CD Pipeline / Validate - Security (push) Successful in 11m43s
CI/CD Pipeline / Unit Tests (push) Successful in 13m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-19 00:58:17 +08:00
xiaoxia 9af73dcd86 fix: #1970 migration 编号冲突修复 081→082 (down_revision 链入 081_add_gpu_lipsync)
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 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (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 Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web 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 / Build Staging API 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 / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m59s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
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 / Frontend Unit Tests (push) Successful in 4m8s
CI/CD Pipeline / Integration Tests (push) Failing after 5m33s
CI/CD Pipeline / Validate - Style (push) Successful in 6m54s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m17s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 2h51m21s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2h58m15s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2h58m58s
CI/CD Pipeline / Unit Tests (push) Failing after 2h59m1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 2h59m1s
CI/CD Pipeline / Validate - Security (push) Failing after 2h59m2s
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 / Retag skipped Staging Web Image (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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m28s
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 (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-18 21:27:04 +08:00
xiaoxia 6002f7a5e4 fix(gpu): result接口上报不存在task返回404而非500 2026-09-18 21:25:31 +08:00
xiaoxia 7e88440ca9 feat: #1970 片段级 AI 标签 + 叙事加权匹配 + 冗余核查 (#1981)
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 (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (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
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 (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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / 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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m40s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / Integration Tests (push) Failing after 7m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 7m20s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (push) Successful in 8m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 13m44s
CI/CD Pipeline / Unit Tests (push) Successful in 14m12s
CI/CD Pipeline / Validate - Security (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 / 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
feat: #1970 片段级 AI 标签 + 叙事加权匹配

- atom_clip_tagger.py: MediaKit 抽帧 + 豆包视觉 API 识别
- narrative_match.py: AI 标签加权匹配 (2.0 vs 1.0)
- Celery 链式触发 + 批量回填脚本
- migration 081 加 ai_tags 列
- 42 新测试,全量 15796 passed
2026-09-18 21:08:01 +08:00
xiaoxia fbf8844f25 feat(gpu): #1978 MuseTalk GPU Worker 反向轮询对接(后端API + Worker脚本) (#1979)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 2s
AI Code Review / AI Code Review (pull_request) Failing after 3s
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) 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 / 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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (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 / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / PR Build API Image (pull_request) Successful in 1m52s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m31s
CI/CD Pipeline / Integration Tests (push) Successful in 7m15s
CI/CD Pipeline / Validate - Style (push) Successful in 7m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m57s
CI/CD Pipeline / Unit Tests (push) Successful in 13m56s
CI/CD Pipeline / Validate - Security (push) Successful in 22m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-18 19:59:49 +08:00
21 changed files with 2174 additions and 245 deletions
+26
View File
@@ -0,0 +1,26 @@
"""add ai_tags to asset_atom_clips for #1970 fragment-level AI tagging
Revision ID: 082_atom_clip_ai_tags
Revises: 081_add_gpu_lipsync
Create Date: 2026-09-18
"""
import sqlalchemy as sa
from alembic import op
revision = "082_atom_clip_ai_tags"
down_revision = "081_add_gpu_lipsync"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"asset_atom_clips",
sa.Column("ai_tags", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("asset_atom_clips", "ai_tags")
+117
View File
@@ -0,0 +1,117 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
for (let i = 0; i <= 2; i++) {
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (r.status() !== 429) {
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
return (await r.json()).access_token as string
}
console.log(`[douyin] 429 retry ${i + 1}/2`)
await new Promise((res) => setTimeout(res, 65000))
}
throw new Error("Login retries exhausted")
}
/**
* #1972 抖音文案提取冒烟
*
* 路径:文案库页面 → 点「🎬 从抖音提取」→ 粘贴分享文案 → 点「开始提取」
* → mock /api/v1/scripts/extract-from-douyin 返回稳定文案 → 断言「新建文案」弹窗中预填了非空文案
*/
test.describe("Douyin Script Extraction (#1972)", () => {
test("extract flow: open modal, paste link, text prefilled in create modal", async ({
page,
request,
}) => {
test.setTimeout(180_000)
await page.setViewportSize({ width: 1440, height: 900 })
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-douyin-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_dy_${suffix}` },
})
const token = await loginWithRetry(request, email, PASSWORD)
const authHeader = { Authorization: `Bearer ${token}` }
const proj = await request.post(`${apiBase}/projects`, {
headers: authHeader,
data: { name: `Smoke Douyin ${suffix}` },
})
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
await request.post(`${apiBase}/asset-libraries`, {
headers: authHeader,
data: { project_id: projectId, name: "Smoke", kind: "video" },
})
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// Mock 抖音提取接口返回稳定文案
const extractedText = "大家好,今天给大家推荐一款超好用的产品,性价比非常高,快来看看吧!"
await page.route("**/api/v1/scripts/extract-from-douyin", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ text: extractedText, duration_seconds: 15 }),
}),
)
// 文案列表空态
await page.route(
(url) => url.pathname.endsWith("/scripts") && !url.pathname.includes("extract-from-douyin"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [], total: 0, page: 1, page_size: 20 }),
}),
)
await page.goto("/app/scripts")
// 文案库页面加载
await expect(page.getByText(/文案库|文案/).first()).toBeVisible({ timeout: 30000 })
// 点「🎬 从抖音提取」按钮
await page.getByRole("button", { name: /从抖音提取/ }).click()
await expect(page.getByText("从抖音视频提取文案")).toBeVisible({ timeout: 5000 })
// 在 TextArea 粘贴"抖音分享文案"
const textarea = page.locator(".ant-modal textarea").first()
await expect(textarea).toBeVisible()
await textarea.fill("8.88 复制打开抖音,看看【推荐视频】https://v.douyin.com/abcDEF/")
// 点「开始提取」
await page.getByRole("button", { name: "开始提取" }).click()
await expect(page.getByText(/提取中/)).toBeVisible({ timeout: 3000 })
// 等待抖音弹窗关闭,「新建文案」弹窗打开并预填提取文案
await expect(page.getByText("从抖音视频提取文案")).not.toBeVisible({ timeout: 15000 })
await expect(page.getByText("新建文案")).toBeVisible({ timeout: 5000 })
const createTextarea = page.locator(".ant-modal textarea").first()
await expect(createTextarea).toBeVisible()
await expect(createTextarea).toHaveValue(new RegExp(extractedText.slice(0, 10)))
console.log("[douyin] Extraction flow completed ✓, text length:", extractedText.length)
})
})
+321 -238
View File
@@ -1,4 +1,4 @@
import { expect, test, type APIRequestContext } from "@playwright/test"
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
@@ -8,7 +8,8 @@ const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
@@ -24,276 +25,358 @@ async function loginWithRetry(
email: string,
password: string,
maxRetries = 2,
) {
): Promise<string> {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
})
if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
const resp = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (resp.status() !== 429) {
expect(resp.ok(), `Login should succeed: ${await resp.text()}`).toBeTruthy()
const data = await resp.json()
return data.access_token
}
console.log(`[login] 429 rate limited, retry ${i + 1}/${maxRetries} after 65s`)
await new Promise((r) => setTimeout(r, 65000))
}
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
throw new Error("Login failed after retries")
}
/**
* 注册新用户 + 建项目/视频库/上传 sample.mp4,等素材 ready。返回 { token, projectId, libraryId, assetId }。
*/
async function setupFreshUser(
request: APIRequestContext,
label: string,
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-${label}-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_${label}_${suffix}` },
})
}
const token = await loginWithRetry(request, email, PASSWORD)
const auth = { Authorization: `Bearer ${token}` }
type ProjectResponse = { id: string }
type LibraryResponse = { id: string }
type AssetListResponse = {
items: Array<{
id: string
name: string
status: string
}>
}
const proj = await request.post(`${apiBase}/projects`, {
headers: auth,
data: { name: `Smoke ${label} ${suffix}` },
})
expect(proj.ok(), `create project: ${await proj.text()}`).toBeTruthy()
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 360_000 })
const lib = await request.post(`${apiBase}/asset-libraries`, {
headers: auth,
data: { project_id: projectId, name: "Smoke", kind: "video" },
})
expect(lib.ok(), `create library: ${await lib.text()}`).toBeTruthy()
const libraryId = (await lib.json()).id
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
test.setTimeout(360_000)
await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36)
const email = `e2e-gen-${suffix}@example.com`
const username = `e2e_gen_${suffix}`
const libraryName = `E2E Gen Lib ${suffix}`
// Register
const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username },
})
expect(register.status()).toBe(201)
const registerData = (await register.json()) as { user_id: string }
// Login
const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }
// Create project
const project = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `E2E Gen Proj ${suffix}` },
})
expect(project.status()).toBe(200)
const projectData = (await project.json()) as ProjectResponse
// Create asset library
const library = await request.post(`${apiBase}/asset-libraries`, {
headers,
data: { project_id: projectData.id, name: libraryName, kind: "video" },
})
expect(library.status()).toBe(200)
const libraryData = (await library.json()) as LibraryResponse
// Upload source video
const sourceFileName = "e2e-gen-source.mp4"
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
const upload = await request.post(`${apiBase}/upload`, {
headers,
multipart: {
project_id: projectData.id,
library_id: libraryData.id,
file: {
name: sourceFileName,
mimeType: "video/mp4",
buffer: sampleVideoBuffer,
},
const samplePath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleBuf = fs.readFileSync(samplePath)
const up = await request.post(`${apiBase}/upload`, {
headers: auth,
multipart: {
project_id: projectId,
library_id: libraryId,
file: {
name: "sample.mp4",
mimeType: "video/mp4",
buffer: sampleBuf,
},
})
expect(upload.status()).toBe(200)
},
})
expect(up.ok(), `upload sample: ${await up.text()}`).toBeTruthy()
const assetId = (await up.json()).asset_id
await expect
.poll(
async () => {
const r = await request.get(`${apiBase}/assets/${assetId}`, { headers: auth })
return r.ok() ? (await r.json()).status : "pending"
},
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
)
.toBe("ready")
return { token, libraryId, assetId, suffix }
}
// Wait for asset to be ready
await expect
.poll(
async () => {
const assets = await request.get(`${apiBase}/assets`, {
headers,
params: { library_id: libraryData.id },
})
if (!assets.ok()) return `http_${assets.status()}`
const data = (await assets.json()) as AssetListResponse
const asset = data.items.find((a) => a.name === sourceFileName)
if (!asset) return "missing"
return asset.status
},
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
/**
* #1970 智能剪辑核心冒烟(新 5 步向导)
*
* 新流程:选择模式 → 选择素材 → 选择标题 → 确认生成 → 选择封面
*
* 两条路径:
* 1) 随机混剪(默认)→ Step1 下一步 → 配音选择弹窗 → Step2 选素材 → 数量弹窗
* → Step3 标题 → Step4 确认生成 → 断言任务创建
* 2) 叙事剪辑 → Step1 切模式 → 下一步 → 文案选择弹窗 → TTS 弹窗选音色(mock 合成)
* → Step2 AI 提示卡可见 + 选素材 → 数量弹窗 → Step3 标题 → Step4 确认生成
* → 断言任务创建
*/
test.describe("Core Smart-Edit Flow (#1970)", () => {
test("random mode: 5-step wizard creates generation task", async ({ page, request }) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, suffix } = await setupFreshUser(request, "random")
const authHeader = { Authorization: `Bearer ${token}` }
// 确保默认模板存在(智能剪辑页依赖模板)
const tmpls = await request.get(`${apiBase}/templates`, { headers: authHeader })
const tmplsJson = await tmpls.json()
const templates = Array.isArray(tmplsJson)
? tmplsJson
: Array.isArray(tmplsJson.items)
? tmplsJson.items
: []
expect(templates.length).toBeGreaterThan(0)
// 注入登录态 + 路由 API
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
.toBe("ready")
}, token)
await routeBrowserApiToTestApi(page)
// GET /templates auto-creates a default template for new users
const templatesResp = await request.get(`${apiBase}/templates`, { headers })
expect(templatesResp.status(), await templatesResp.text()).toBe(200)
const templatesData = (await templatesResp.json()) as {
items: Array<{ id: string }>
}
expect(Array.isArray(templatesData.items)).toBe(true)
expect(templatesData.items.length).toBeGreaterThan(0)
const templateId = templatesData.items[0].id
expect(templateId).toBeTruthy()
// Set auth in localStorage
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token)
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice ──
await page.route(
(url) => url.pathname.endsWith("/assets") && url.searchParams.get("kind") === "voice",
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: `asset-voice-${suffix}`,
name: "测试配音.mp3",
file_url: "data:audio/mpeg;base64,",
duration: 10,
file_size: 1024,
kind: "voice",
status: "ready",
},
],
total: 1,
}),
)
},
{
token: loginData.access_token,
user: {
id: registerData.user_id,
user_id: registerData.user_id,
email,
username,
display_name: username,
is_email_verified: true,
email_verified: true,
},
},
}),
)
// Navigate to generate page
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 20_000,
timeout: 30000,
})
// 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5)
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await expect(page.getByText("随机混剪")).toBeVisible()
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 1: 素材选择 ──
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName })
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
await expect(materialCard).toBeVisible({ timeout: 10_000 })
await materialCard.click({ position: { x: 15, y: 15 } })
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
await page.getByRole("button", { name: "下一步" }).click()
// ── 配音选择弹窗:选第一个配音 → 确认 ─────────────────────────
await expect(page.getByText("🎙️ 选择配音")).toBeVisible({ timeout: 5000 })
await page.getByText("测试配音.mp3").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
// ── 数量弹窗(PreviewCountModal ──
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
timeout: 5_000,
})
// ── Step 2:选择素材 ──────────────────────────────────────────
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗:默认 1 个 → 确认 ───────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 2: 配音(新注册用户无配音素材,跳过) ──
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// ── Step 3: 标题设置 ──
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
await page.waitForTimeout(2000)
const titleInput = page.locator(".ant-select-auto-complete input")
// ── Step 3:填写标题 ──────────────────────────────────────────
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput).toBeVisible({ timeout: 5000 })
await titleInput.fill(`E2E Test ${suffix}`)
await titleInput.fill(`测试随机剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// Step 3 底部是「下一步 →」,点击进入 Step 4确认生成
await page.getByRole("button", { name: "下一步" }).click()
// ── Step 4确认生成 ──────────────────────────────────────────
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("随机混剪")).toBeVisible()
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn).toBeEnabled({ timeout: 5000 })
// ── Step 4: 确认生成 ──
// 等待实时预览就绪(占位消失)
await page
.getByText("准备预览素材")
.waitFor({ state: "detached", timeout: 30_000 })
.catch(() => {})
const createTask = page.waitForResponse(
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
{ timeout: 30000 },
)
await confirmBtn.click()
const taskResp = await createTask
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
console.log("[random] Generation task created:", taskId)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[random] Wizard flow completed ✓")
})
// Step 4 底部是「✨ 确认生成视频」
const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first()
await expect(confirmBtn).toBeVisible({ timeout: 15_000 })
test("narrative mode: select script + mock TTS, create generation task", async ({
page,
request,
}) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, suffix } = await setupFreshUser(request, "narrative")
// 先挂 API 监听再点击
const generatePromise = page.waitForResponse(
(response) => {
const url = response.url()
const path = new URL(url).pathname
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
},
{ timeout: 30_000 },
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// ── Mock 文案列表、音色、TTS 合成(避免真实合成) ──────────────
const mockScriptId = `script-mock-${suffix}`
const mockVoiceId = `preset-voice-${suffix}`
const mockJobId = `tts-job-${suffix}`
// 文案列表(ScriptSelectModal 查询 /scripts
await page.route("**/api/v1/scripts**", (route) => {
const url = new URL(route.request().url())
if (url.pathname.includes("/extract-from-douyin")) {
route.continue()
return
}
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: mockScriptId,
title: "测试带货文案",
content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。",
tags: ["带货"],
title_category: "daihuo",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
total: 1,
page: 1,
page_size: 200,
}),
})
})
// 预设音色(TtsVoiceModal 查询 GET /voices/presets
await page.route("**/api/v1/voices/presets**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
voice_id: mockVoiceId,
name: "晓晓(女声)",
description: "温柔女声",
gender: "female",
language: "zh-CN",
preview_url: null,
tags: ["温柔"],
},
],
total: 1,
}),
}),
)
await confirmBtn.click()
// 克隆音色:空列表
await page.route(
(url) => url.pathname.endsWith("/voice-clones"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
}),
)
// 验证生成 API 被调用
const genResp = await generatePromise.catch(() => null)
if (!genResp) {
// staging 预览未就绪导致按钮校验拦截,未触发 API — 向导导航仍通过
console.log(
"[E2E] Generation API not triggered (preview not ready) — wizard navigation verified",
)
} else if (genResp.ok()) {
const genData = (await genResp.json()) as {
items: Array<{ id: string; status: string }>
total: number
}
expect(genData.items.length).toBeGreaterThan(0)
// TTS 合成:直接返回 completed 任务
await page.route("**/api/v1/tts/synthesize", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ job_id: mockJobId, status: "queued" }),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
job_id: mockJobId,
status: "completed",
progress: 100,
audio_url: "data:audio/mpeg;base64,",
duration: 5,
}),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }),
}),
)
// race:渲染完成 vs 生成失败/超时
const downloadReady = page
.getByText("视频生成完成")
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "completed" : null))
const generationFailed = page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "failed" : null))
const outcome = await Promise.any([downloadReady, generationFailed]).catch(() => "timeout")
if (outcome === "completed") {
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 30_000,
})
} else {
console.log(`[E2E] Video rendering ${outcome} on staging — wizard flow verified`)
}
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
}
// 验证成品库页面加载
await page.goto("/app/products")
await expect(page).toHaveURL(/\/app\/products/)
await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000 })
await page.unrouteAll({ behavior: "ignoreErrors" })
})
test("generation task API creates and lists tasks", async ({ request }) => {
const suffix = Date.now().toString(36)
const email = `e2e-gen-api-${suffix}@example.com`
const username = `e2e_gen_api_${suffix}`
const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username },
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
})
expect(register.status()).toBe(201)
const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await page.getByText("叙事剪辑").click()
await page.getByRole("button", { name: /下一步/ }).click()
const project = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `E2E API Proj ${suffix}` },
})
expect(project.status()).toBe(200)
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
await page.getByText("测试带货文案").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
const tasks = await request.get(`${apiBase}/tasks`, { headers })
expect(tasks.status()).toBe(200)
const tasksData = await tasks.json()
expect(Array.isArray(tasksData.items)).toBe(true)
// ── TTS 音色弹窗:选系统音色 → 合成 ─────────────────────────
await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 })
await page.getByText("晓晓(女声)").first().click()
await page.getByRole("button", { name: "🎧 合成配音" }).click()
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗 ─────────────────────────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput2).toBeVisible({ timeout: 5000 })
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("叙事剪辑")).toBeVisible()
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn2).toBeEnabled({ timeout: 5000 })
const createTask2 = page.waitForResponse(
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
{ timeout: 30000 },
)
await confirmBtn2.click()
const taskResp2 = await createTask2
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[narrative] Wizard flow completed ✓")
})
})
+105
View File
@@ -0,0 +1,105 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
for (let i = 0; i <= 2; i++) {
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (r.status() !== 429) {
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
return (await r.json()).access_token as string
}
console.log(`[nav] 429 retry ${i + 1}/2`)
await new Promise((res) => setTimeout(res, 65000))
}
throw new Error("Login retries exhausted")
}
/**
* 核心页面导航冒烟:侧边栏主要入口能访问、文案库/配音库页面能正常加载(不出白屏/无致命 js error)
*/
test.describe("Core Navigation", () => {
let authToken: string
test.beforeAll(async ({ request }) => {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-nav-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_nav_${suffix}` },
})
authToken = await loginWithRetry(request, email, PASSWORD)
const authHeader = { Authorization: `Bearer ${authToken}` }
const proj = await request.post(`${apiBase}/projects`, {
headers: authHeader,
data: { name: `Smoke Nav ${suffix}` },
})
if (proj.ok()) {
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
await request.post(`${apiBase}/asset-libraries`, {
headers: authHeader,
data: { project_id: projectId, name: "Nav Lib", kind: "video" },
})
}
})
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, authToken)
await routeBrowserApiToTestApi(page)
})
const navCases = [
{ path: "/app/dashboard", marker: /概览|工作台|最近/i, name: "概览" },
{ path: "/app/generate", marker: /智能剪辑|剪辑/, name: "智能剪辑" },
{ path: "/app/assets", marker: /视频库|素材/, name: "视频库" },
{ path: "/app/scripts", marker: /文案/, name: "文案库" },
{ path: "/app/voices", marker: /配音|我的音色|配音库/, name: "配音库" },
{ path: "/app/products", marker: /成品|作品/, name: "成品库" },
{ path: "/app/history", marker: /历史|任务/, name: "任务历史" },
{ path: "/app/tasks", marker: /任务中心|任务列表/, name: "任务中心" },
{ path: "/app/points", marker: /积分|我的积分/, name: "积分中心" },
]
for (const c of navCases) {
test(`visit ${c.name} (${c.path}) loads without fatal pageerror`, async ({ page }) => {
const errors: Error[] = []
page.on("pageerror", (e) => errors.push(e))
await page.goto(c.path)
await expect(page.locator("body")).not.toBeEmpty({ timeout: 20000 })
// 过滤掉常见第三方/非致命错误
const fatal = errors.filter(
(e) =>
!/ResizeObserver|Loading chunk|network error|Failed to fetch|chunkLoadError/i.test(
e.message,
),
)
expect(fatal, `${c.name} pageerrors: ${fatal.map((e) => e.message).join("; ")}`).toHaveLength(
0,
)
await expect(
page.getByText(c.marker).first(),
`${c.name} should show relevant text`,
).toBeVisible({ timeout: 15000 })
console.log(`[nav] ${c.name} loaded ✓`)
})
}
})
@@ -493,6 +493,41 @@ class RenderAdapter:
logger.warning("ASR 服务初始化失败,自动字幕将不可用: %s", e)
return None
def _resolve_clip_has_text(self, clips: list[Any]) -> list[bool] | None:
"""#1970:按源视频片段顺序解析 atom_clip.ai_tags.has_text。
顺序与 UnifiedRenderService 的「非 audio 源片段」口径一致。
仅当 atom_clip 存在 ai_tags 字典且 has_text 显式为 False 时标记为
无文字(允许 hflip);atom_clip_id 缺失、ai_tags 未生成、has_text 为
true/null/非布尔值时一律按有文字处理(保守不翻转)。
查询失败时返回 None,渲染层回退到全保守路径。
"""
video_clips = [c for c in clips if getattr(c, "clip_type", "main") != "audio"]
atom_ids: list[str] = []
seen: set[str] = set()
for c in video_clips:
atom_id = getattr(c, "atom_clip_id", "") or ""
if atom_id and atom_id not in seen:
seen.add(atom_id)
atom_ids.append(atom_id)
if not atom_ids:
return None
try:
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
atom_clips = SQLAlchemyAssetAtomClipRepository(self._db).find_by_ids(atom_ids)
except Exception as exc:
logger.warning("[render-adapter] atom_clip ai_tags 查询失败,hflip 全量保守处理: %s", exc)
return None
has_text_map: dict[str, bool] = {}
for ac in atom_clips:
ai_tags = getattr(ac, "ai_tags", None)
no_text = isinstance(ai_tags, dict) and ai_tags.get("has_text") is False
has_text_map[ac.id] = not no_text
return [has_text_map.get((getattr(c, "atom_clip_id", "") or ""), True) for c in video_clips]
def _do_render(
self,
plan: Any,
@@ -542,6 +577,7 @@ class RenderAdapter:
)
# 4. 执行统一渲染
clip_has_text = self._resolve_clip_has_text(clips)
render_svc = UnifiedRenderService(
plan=plan,
clips=clips,
@@ -552,6 +588,7 @@ class RenderAdapter:
bgm_path=bgm_path,
asr_service=asr_service,
voiceover_audio_path=voiceover_audio_path,
clip_has_text=clip_has_text,
)
result = render_svc.render()
@@ -155,6 +155,7 @@ class UnifiedRenderService:
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
bgm_path: str | None = None, # BGM 本地文件路径
voiceover_audio_path: str | None = None, # 配音素材库音频本地路径
clip_has_text: list[bool] | None = None, # 源视频片段是否有文字(来自 atom_clip.ai_tags.has_text
):
self.plan = plan
self.clips = clips
@@ -167,6 +168,8 @@ class UnifiedRenderService:
self.asr_service = asr_service
self.bgm_path = bgm_path
self.voiceover_audio_path = voiceover_audio_path
# #1970:片段级文字检测(顺序与非 audio 的源视频片段一致);None 表示无可靠检测,保守不翻转
self._clip_has_text = clip_has_text
self._transition_engine = TransitionEngine(default_duration=transition_duration)
self._speed_engine = SpeedEngine()
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
@@ -186,7 +189,9 @@ class UnifiedRenderService:
种子 hash(generation_task_id + video_index)%10000,同一任务重渲结果一致。
dedup_enabled=False 时返回 None,调用方不注入任何微变换。
P1 字幕检测:无可靠的片段文字轨道信息,hflip 一律关闭(宁可不翻转)。
hflip 放开(#1970):clip_has_text 来自 atom_clip.ai_tags.has_text
仅 AI 明确判定无文字的片段可参与 50% 翻转;未打标签 / has_text 为
true/null 或缺位时一律视为有文字,保持保守不翻转。
"""
if self._micro_plan_loaded:
return self._micro_plan_cache
@@ -200,11 +205,14 @@ class UnifiedRenderService:
cfg = self.plan.config or {}
task_id = str(cfg.get("generation_task_id", "") or "")
video_index = int(cfg.get("video_index", 0) or 0)
# self._clip_has_text 顺序与非 audio 源片段一致;
# None(未提供检测,如内存直渲/旧任务)→ 纯函数层按全有文字保守处理;
# 列表短于片段数时缺位片段同样按有文字处理
self._micro_plan_cache = build_micro_transform_plan(
task_id,
video_index,
clip_count,
clip_has_text=None, # P1 保守策略:全部按有文字处理,不翻转
clip_has_text=self._clip_has_text,
enable_bgm_offset=bool(cfg.get("bgm")),
)
except Exception as e:
+8
View File
@@ -57,6 +57,14 @@ def __getattr__(name: str):
from .atom_clips import generate_atom_clips
return generate_atom_clips
elif name == "tag_atom_clip_task":
from .atom_clip_tagging import tag_atom_clip_task
return tag_atom_clip_task
elif name == "backfill_atom_clip_tags":
from .backfill_atom_clip_tags import backfill_atom_clip_tags
return backfill_atom_clip_tags
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,93 @@
"""片段级 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
为单个 atom_clip 调用视觉 AI 生成结构化标签,并更新到 ai_tags 字段。
失败不阻断流程(降级为仅继承素材标签)。
任务名:worker.tag_atom_clip
"""
from __future__ import annotations
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.domain.atom_clip_tagger import tag_atom_clip
from packages.shared.ai_client import get_doubao_client
from packages.shared.mediakit_client import get_mediakit_client
from packages.shared.storage import get_shared_storage_service
logger = get_task_logger(__name__)
@celery_app.task(name="worker.tag_atom_clip", bind=True, max_retries=2, default_retry_delay=10)
def tag_atom_clip_task(self, atom_clip_id: str) -> dict:
"""为单个原子片段生成 AI 标签.
Args:
atom_clip_id: 原子片段 ID。
Returns:
任务结果 dictstatus / clip_id / ai_tags(部分字段)。
"""
db = SessionLocal()
try:
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
asset_repo = SQLAlchemyAssetRepository(db)
clip = atom_repo.find_by_id(atom_clip_id)
if clip is None:
return {"status": "skipped", "reason": "clip not found", "clip_id": atom_clip_id}
# 已有标签则跳过(幂等)
if clip.ai_tags is not None:
return {"status": "skipped", "reason": "already tagged", "clip_id": atom_clip_id}
# 获取素材信息
asset = asset_repo.find_by_id(clip.asset_id)
if asset is None:
return {"status": "skipped", "reason": "asset not found", "clip_id": atom_clip_id}
# 获取视频可访问 URL
storage = get_shared_storage_service()
video_url = storage.get_download_url(asset.storage_key, expires_seconds=3600)
# 初始化客户端
doubao_client = get_doubao_client()
mediakit_client = get_mediakit_client()
# 调用 tagger
ai_tags = tag_atom_clip(
clip=clip,
video_url=video_url,
doubao_client=doubao_client,
mediakit_client=mediakit_client,
storage=storage,
)
# 更新数据库
atom_repo.update_ai_tags(atom_clip_id, ai_tags)
logger.info(
"[atom_clip_tagging] clip_id=%s ai_tags=%s",
atom_clip_id,
{k: v for k, v in ai_tags.items() if k != "inherited_tags"},
)
return {
"status": "completed",
"clip_id": atom_clip_id,
"has_ai_tags": any(v for k, v in ai_tags.items() if k != "inherited_tags" and v),
}
except Exception as exc:
db.rollback()
logger.exception("[atom_clip_tagging] clip_id=%s 失败: %s", atom_clip_id, exc)
# 可重试异常
if self.request.retries < self.max_retries:
raise self.retry(exc=exc) from None
return {"status": "failed", "clip_id": atom_clip_id, "error": str(exc)}
finally:
db.close()
@@ -3,6 +3,8 @@
素材入库预处理完成(ingest 置 READY)后异步触发:
根据素材时长和已缓存的 scdet 切换点计算原子片段并落库。
失败不阻断素材入库主流程(atom_clips 未就绪时选片有内存兜底)。
P2 增强:切片完成后自动链式触发 AI 标签任务(每个 clip 一个 tag_atom_clip 任务)。
"""
from __future__ import annotations
@@ -72,6 +74,10 @@ def generate_atom_clips(asset_id: str) -> dict:
asset_id,
len(clips),
)
# P2 增强:链式触发 AI 标签任务(每个 clip 一个异步任务)
_dispatch_tagging_tasks(clips)
return {"status": "completed", "asset_id": asset_id, "clips_count": len(clips)}
except Exception as exc: # noqa: BLE001 - 后台任务兜底,失败不阻断主流程
db.rollback()
@@ -79,3 +85,25 @@ def generate_atom_clips(asset_id: str) -> dict:
return {"status": "failed", "asset_id": asset_id, "error": str(exc)}
finally:
db.close()
def _dispatch_tagging_tasks(clips: list) -> None:
"""为每个新建片段发送 AI 标签异步任务.
失败不阻断(标签任务是锦上添花,不影响核心流程)。
"""
try:
for clip in clips:
celery_app.send_task(
"worker.tag_atom_clip",
args=[clip.id],
)
logger.info(
"[atom_clips] 已发送 %d 个 AI 标签任务",
len(clips),
)
except Exception as e:
logger.warning(
"[atom_clips] 发送 AI 标签任务失败(不影响切片结果): %s",
e,
)
@@ -0,0 +1,102 @@
"""批量回填 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
查找所有 ai_tags IS NULL 的 atom_clips,分批触发 tag_atom_clip 任务。
可通过 API 路由触发(管理员权限)。
任务名:worker.backfill_atom_clip_tags
"""
from __future__ import annotations
import time
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
SQLAlchemyAssetAtomClipRepository,
)
logger = get_task_logger(__name__)
# 默认批量参数
DEFAULT_BATCH_SIZE = 10
DEFAULT_BATCH_INTERVAL = 5 # 秒
@celery_app.task(name="worker.backfill_atom_clip_tags")
def backfill_atom_clip_tags(
batch_size: int = DEFAULT_BATCH_SIZE,
batch_interval: int = DEFAULT_BATCH_INTERVAL,
max_clips: int = 0,
) -> dict:
"""批量回填未打标的 atom_clips.
Args:
batch_size: 每批处理数量,默认 10。
batch_interval: 每批间隔秒数,默认 5。
max_clips: 最大处理总数,0 表示不限。
Returns:
任务结果 dicttotal_submitted / batches。
"""
db = SessionLocal()
try:
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
total_submitted = 0
batches = 0
while True:
# 查找未打标的片段
remaining = max_clips - total_submitted if max_clips > 0 else batch_size
fetch_limit = min(batch_size, remaining) if max_clips > 0 else batch_size
untagged = atom_repo.find_untagged(limit=fetch_limit)
if not untagged:
break
# 逐个发送 tag 任务
for clip in untagged:
try:
celery_app.send_task(
"worker.tag_atom_clip",
args=[clip.id],
)
total_submitted += 1
except Exception as e:
logger.warning(
"[backfill] 提交任务失败 clip_id=%s: %s",
clip.id,
e,
)
batches += 1
logger.info(
"[backfill] 第 %d 批完成,已提交 %d 个任务",
batches,
total_submitted,
)
# 检查是否达到上限
if max_clips > 0 and total_submitted >= max_clips:
break
# 批间间隔
time.sleep(batch_interval)
logger.info(
"[backfill] 回填完成: total_submitted=%d batches=%d",
total_submitted,
batches,
)
return {
"status": "completed",
"total_submitted": total_submitted,
"batches": batches,
}
except Exception as exc:
logger.exception("[backfill] 回填失败: %s", exc)
return {"status": "failed", "error": str(exc)}
finally:
db.close()
@@ -83,6 +83,25 @@ class SQLAlchemyAssetAtomClipRepository:
models = query.all()
return [self._to_domain(m) for m in models]
def update_ai_tags(self, clip_id: str, ai_tags: dict) -> bool:
"""更新指定片段的 ai_tags 字段."""
count = (
self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id == clip_id).update({"ai_tags": ai_tags})
)
self.session.commit()
return count > 0
def find_untagged(self, limit: int = 100) -> list[AssetAtomClip]:
"""查找 ai_tags IS NULL 的片段,用于回填."""
models = (
self.session.query(AssetAtomClipModel)
.filter(AssetAtomClipModel.ai_tags.is_(None))
.order_by(AssetAtomClipModel.created_at.asc())
.limit(limit)
.all()
)
return [self._to_domain(m) for m in models]
def _to_model(self, clip: AssetAtomClip) -> AssetAtomClipModel:
return AssetAtomClipModel(
id=clip.id,
@@ -92,6 +111,7 @@ class SQLAlchemyAssetAtomClipRepository:
duration=clip.duration,
clip_index=clip.clip_index,
tags=clip.tags,
ai_tags=clip.ai_tags,
scene_change_at=clip.scene_change_at,
is_fallback=clip.is_fallback,
created_at=clip.created_at or datetime.now(UTC),
@@ -837,6 +837,7 @@ class AssetAtomClipModel(Base):
duration = Column(Float, nullable=False)
clip_index = Column(Integer, nullable=False)
tags = Column(JSON, nullable=False, default=list)
ai_tags = Column(JSON, nullable=True, default=None)
scene_change_at = Column(Float, nullable=True)
is_fallback = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
+1
View File
@@ -68,6 +68,7 @@ class SharedSettings(BaseSettings):
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
doubao_timeout: int = 30
doubao_max_retries: int = 2
doubao_vision_model: str = "doubao-1-5-vision-pro-250915"
# ── MediaKit (火山引擎 AI 媒体工具) ──────────────────────────────────
mediakit_api_key: str = ""
+1
View File
@@ -36,6 +36,7 @@ class AssetAtomClip:
duration: float
clip_index: int
tags: list[str] = field(default_factory=list)
ai_tags: dict | None = None
scene_change_at: float | None = None
is_fallback: bool = False
created_at: datetime | None = None
+292
View File
@@ -0,0 +1,292 @@
"""片段级 AI 标签 — #1970 智能剪辑流程重构 P2.
对每个 atom_clip 提取关键帧,调用豆包视觉理解 API 识别内容,
生成结构化标签(场景、物体、动作、景别、是否有文字)。
纯函数 + IO 分离设计:
- build_vision_prompt() 返回结构化 prompt
- parse_vision_response(text) 解析 AI 返回的 JSON 标签
- tag_atom_clip(...) 主入口,组合帧提取 → 视觉 API → 解析标签
降级策略:任何环节失败都返回 {"inherited_tags": clip.tags},不阻断流程。
"""
from __future__ import annotations
import json
import logging
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
# AI 标签结构的键
AI_TAG_KEYS = ("scene", "objects", "action", "shot", "has_text")
def build_vision_prompt() -> str:
"""返回结构化标签提取 prompt.
要求 AI 以 JSON 格式返回片段内容标签,包含:
- scene: 场景类型列表(如 "工厂", "办公室", "户外"
- objects: 出现的物体列表(如 "产品", "手机", "电脑"
- action: 动作类型列表(如 "演示", "说话", "操作"
- shot: 景别("特写" / "中景" / "远景" 之一)
- has_text: 画面中是否有显著文字(true/false)
"""
return """请分析这段视频片段的关键帧,识别内容并返回 JSON 格式标签。
要求返回以下 JSON 结构(严格 JSON,不要添加其他文字):
{
"scene": ["场景1", "场景2"],
"objects": ["物体1", "物体2"],
"action": ["动作1"],
"shot": "特写|中景|远景",
"has_text": true/false
}
规则:
- scene: 场景类型,如"工厂""办公室""户外""商店""家庭"等,1-3个
- objects: 画面中可见的主要物体,如"产品""手机""电脑""食品"等,1-5个
- action: 人物或物体正在进行的动作,如"演示""说话""操作""展示"等,1-3个
- shot: 景别判断,只能是"特写""中景""远景"之一
- has_text: 画面中是否有显著可读文字(标题、字幕、标语等)
请只返回 JSON,不要有其他说明文字。"""
def parse_vision_response(text: str) -> dict:
"""解析 AI 返回的 JSON 标签文本.
Args:
text: 视觉 API 返回的文本,期望是 JSON 格式。
Returns:
结构化标签 dict,格式如:
{"scene": [...], "objects": [...], "action": [...], "shot": "...", "has_text": bool}
解析失败时返回空 dict。
"""
if not text or not text.strip():
return {}
# 尝试直接解析
cleaned = text.strip()
# 去除可能的 markdown 代码块包裹
if cleaned.startswith("```"):
lines = cleaned.split("\n")
# 去掉首尾的 ``` 行
start = 1
end = len(lines)
for i in range(len(lines) - 1, 0, -1):
if lines[i].strip().startswith("```"):
end = i
break
cleaned = "\n".join(lines[start:end]).strip()
try:
data = json.loads(cleaned)
except json.JSONDecodeError:
# 尝试从文本中提取 JSON 块
try:
start_idx = cleaned.index("{")
end_idx = cleaned.rindex("}") + 1
data = json.loads(cleaned[start_idx:end_idx])
except (ValueError, json.JSONDecodeError):
logger.warning("无法解析 AI 标签响应: %s", text[:200])
return {}
if not isinstance(data, dict):
return {}
# 验证和清洗各字段
result: dict[str, Any] = {}
for key in ("scene", "objects", "action"):
val = data.get(key)
if isinstance(val, list):
result[key] = [str(v).strip() for v in val if str(v).strip()]
elif isinstance(val, str) and val.strip():
result[key] = [val.strip()]
else:
result[key] = []
shot_val = data.get("shot", "")
if isinstance(shot_val, str) and shot_val.strip() in ("特写", "中景", "远景"):
result["shot"] = shot_val.strip()
else:
result["shot"] = ""
has_text_val = data.get("has_text")
if isinstance(has_text_val, bool):
result["has_text"] = has_text_val
elif isinstance(has_text_val, str):
result["has_text"] = has_text_val.lower() in ("true", "yes", "1")
else:
result["has_text"] = False
return result
def _extract_frames_via_mediakit(
mediakit_client: Any,
video_url: str,
start_time: float,
end_time: float,
) -> Optional[list[str]]:
"""通过 MediaKit 提取 3 帧(首、中、尾).
Returns:
图片 URL 列表(3 个),失败返回 None。
"""
try:
frames = mediakit_client.extract_frames(
video_url=video_url,
strategy="SpecifiedTime",
max_frames=3,
poll_interval=2.0,
max_poll_attempts=30,
)
# MediaKit SpecifiedTime 策略可能不支持直接传时间点
# 如果返回结果不够 3 帧,降级到 ffmpeg
if frames and len(frames) >= 1:
urls = [f.get("image_url", "") for f in frames if f.get("image_url")]
if urls:
return urls
except Exception as e:
logger.warning("MediaKit 抽帧失败,将降级为 ffmpeg: %s", e)
return None
def _extract_frames_via_ffmpeg(
video_url: str,
start_time: float,
end_time: float,
) -> Optional[list[str]]:
"""通过 ffmpeg 本地提取 3 帧并转为 base64.
Returns:
base64 data URI 列表(3 个),失败返回 None。
"""
import base64
mid_time = round((start_time + end_time) / 2, 3)
timestamps = [round(start_time, 3), mid_time, round(end_time, 3)]
try:
frames_b64: list[str] = []
with tempfile.TemporaryDirectory() as tmpdir:
for i, ts in enumerate(timestamps):
out_path = Path(tmpdir) / f"frame_{i}.jpg"
cmd = [
"ffmpeg",
"-y",
"-ss",
str(ts),
"-i",
video_url,
"-vframes",
"1",
"-q:v",
"2",
str(out_path),
]
result = subprocess.run(
cmd,
capture_output=True,
timeout=30,
)
if result.returncode != 0 or not out_path.exists():
logger.warning("ffmpeg 抽帧失败 ts=%s: %s", ts, result.stderr[:200])
continue
img_data = out_path.read_bytes()
b64 = base64.b64encode(img_data).decode("ascii")
frames_b64.append(f"data:image/jpeg;base64,{b64}")
if frames_b64:
return frames_b64
except Exception as e:
logger.warning("ffmpeg 抽帧异常: %s", e)
return None
def tag_atom_clip(
clip: Any,
video_url: str,
doubao_client: Any,
mediakit_client: Any | None = None,
storage: Any | None = None,
) -> dict:
"""主入口:为单个 atom_clip 生成 AI 标签.
流程:提取帧 → 调视觉 API → 解析标签 → 返回结构化标签 dict。
任何环节失败返回 {"inherited_tags": clip.tags},不阻断流程。
Args:
clip: AssetAtomClip 领域对象(需有 start_time, end_time, tags)。
video_url: 素材视频的公网可访问 URL。
doubao_client: DoubaoClient 实例。
mediakit_client: MediaKitClient 实例(可选,不可用时降级 ffmpeg)。
storage: SharedStorageService 实例(可选,用于获取签名 URL)。
Returns:
结构化标签 dict,格式如:
{"scene": [...], "objects": [...], "action": [...], "shot": "...",
"has_text": bool, "inherited_tags": [...]}
"""
inherited = list(getattr(clip, "tags", []) or [])
# 检查 DoubaoClient 是否可用
if not getattr(doubao_client, "is_available", False):
logger.info("DoubaoClient 不可用,跳过 AI 标签: clip_id=%s", getattr(clip, "id", ""))
return {"inherited_tags": inherited}
# 提取帧图片
frame_urls: Optional[list[str]] = None
start_time = getattr(clip, "start_time", 0.0)
end_time = getattr(clip, "end_time", 0.0)
# 优先使用 MediaKit
if mediakit_client and getattr(mediakit_client, "is_available", False):
frame_urls = _extract_frames_via_mediakit(mediakit_client, video_url, start_time, end_time)
# MediaKit 不可用或失败 → 降级 ffmpeg
if not frame_urls:
frame_urls = _extract_frames_via_ffmpeg(video_url, start_time, end_time)
if not frame_urls:
logger.warning("帧提取失败,跳过 AI 标签: clip_id=%s", getattr(clip, "id", ""))
return {"inherited_tags": inherited}
# 调用视觉 API
prompt = build_vision_prompt()
messages = [{"role": "user", "content": prompt}]
try:
response_text = doubao_client.vision_completion(
messages=messages,
images=frame_urls,
timeout=60,
)
except Exception as e:
logger.warning("视觉 API 调用异常: clip_id=%s error=%s", getattr(clip, "id", ""), e)
return {"inherited_tags": inherited}
if not response_text:
logger.warning("视觉 API 返回空: clip_id=%s", getattr(clip, "id", ""))
return {"inherited_tags": inherited}
# 解析标签
ai_tags = parse_vision_response(response_text)
if not ai_tags:
logger.warning("标签解析失败: clip_id=%s response=%s", getattr(clip, "id", ""), response_text[:200])
return {"inherited_tags": inherited}
# 合并 inherited_tags
ai_tags["inherited_tags"] = inherited
return ai_tags
+133 -5
View File
@@ -1,4 +1,4 @@
"""叙事剪辑素材标签匹配 — #1970 PR3.
"""叙事剪辑素材标签匹配 — #1970 PR3 + P2 AI 标签加权.
叙事模式下,选片在现有评分(smart_match / atom_clip_selector)之前先做一层
文案标签匹配:
@@ -8,6 +8,12 @@
- 调用方对优先池跑现有 smart_select_assets,数量不足时用普通池补足
(无任何匹配 → 完全降级为现有随机逻辑,行为与改造前一致)。
P2 AI 标签加权(#1970 fragment-level AI tagging):
- 片段级 AI 标签(scene/objects/action)与文案标签做交集时权重 2.0
- 素材级标签(tag_ids 映射名)与文案标签交集时权重 1.0
- 综合得分 = sum(命中权重) / max(可能权重)
- 有 AI 标签的片段命中时优先于仅素材标签命中的片段
纯函数模块:标签 id→名称映射由调用方查 TagModel 后注入,不直接碰 DB。
"""
@@ -18,6 +24,10 @@ from typing import Any, Iterable
# 标签归一化后仍短于此长度的标签不参与匹配(避免「的」「是」这类噪声短词)
MIN_TAG_LEN = 2
# 标签匹配权重
AI_TAG_WEIGHT = 2.0 # AI 标签命中权重
ASSET_TAG_WEIGHT = 1.0 # 素材标签命中权重
def normalize_tag(tag: Any) -> str:
"""标签归一化:去空白、小写。数字/英文统一小写,中文不受影响。"""
@@ -47,19 +57,81 @@ def build_asset_tag_name_index(tag_names_by_id: dict[str, Any]) -> dict[str, set
return index
def _extract_ai_tag_names(ai_tags: dict) -> set[str]:
"""从 AI 标签 dict 中提取所有标签名(scene + objects + action.
Args:
ai_tags: 片段级 AI 标签 dict,如 {"scene": [...], "objects": [...], "action": [...], ...}
Returns:
归一化后的标签名集合。
"""
names: set[str] = set()
for key in ("scene", "objects", "action"):
values = ai_tags.get(key)
if isinstance(values, list):
names |= _normalize_tags(values)
return names
def _compute_ai_score(
asset_id: str,
wanted: set[str],
clip_ai_tags_by_asset: dict[str, list[dict]] | None,
) -> float:
"""计算单个素材的 AI 标签加权得分.
对该素材的所有片段 AI 标签,求各片段标签名与文案标签交集的加权总和。
每个片段的命中权重 = 命中数 × AI_TAG_WEIGHT。
最终取所有片段的最高得分(而非累加,避免片段数多的素材不公平占优)。
Args:
asset_id: 素材 ID。
wanted: 归一化后的文案标签集合。
clip_ai_tags_by_asset: {asset_id: [ai_tag_dict, ...]} 每个片段一个。
Returns:
AI 标签加权得分(≥0)。
"""
if not clip_ai_tags_by_asset or not wanted:
return 0.0
clips = clip_ai_tags_by_asset.get(asset_id)
if not clips:
return 0.0
best_score = 0.0
for ai_tags in clips:
if not ai_tags or not isinstance(ai_tags, dict):
continue
ai_names = _extract_ai_tag_names(ai_tags)
hits = ai_names & wanted
score = len(hits) * AI_TAG_WEIGHT
if score > best_score:
best_score = score
return best_score
def match_assets_by_script_tags(
assets: list[Any],
*,
script_tags: Iterable[Any],
tag_names_by_id: dict[str, Any] | None = None,
clip_ai_tags_by_asset: dict[str, list[dict]] | None = None,
) -> tuple[list[Any], list[Any]]:
"""按文案标签把素材拆成「命中池 / 未命中池」,保持输入相对顺序。
P2 加权逻辑:
- AI 标签命中(scene/objects/action ∩ 文案标签)权重 2.0
- 素材标签命中(tag_ids 映射名 ∩ 文案标签)权重 1.0
- 任一权重 > 0 → 命中池,否则 → 未命中池
Args:
assets: 候选素材(domain Asset,需有 id 与 tag_ids)。
script_tags: 文案 tags(字符串数组,名称语义)。
tag_names_by_id: asset_id → 素材标签名列表;素材只有 tag_ids 时由调用方
查 TagModel 名称后传入。为空则视为无素材命中
tag_names_by_id: asset_id → 素材标签名列表
clip_ai_tags_by_asset: #1970 P2 — {asset_id: [ai_tag_dict, ...]}
Returns:
(matched, unmatched):命中任一文案标签的素材 / 其余素材。
@@ -74,23 +146,74 @@ def match_assets_by_script_tags(
unmatched: list[Any] = []
for asset in assets:
asset_id = str(getattr(asset, "id", "") or "")
# P2: AI 标签加权得分
ai_score = _compute_ai_score(asset_id, wanted, clip_ai_tags_by_asset)
# 素材标签得分
names = set(name_index.get(asset_id, set()))
# 兼容素材自身带字符串 tags(旧链路/测试替身)
raw_tags = getattr(asset, "tags", None)
if raw_tags:
names |= _normalize_tags(raw_tags)
if names & wanted:
asset_score = len(names & wanted) * ASSET_TAG_WEIGHT
# 综合得分 > 0 → 命中池
if ai_score > 0 or asset_score > 0:
matched.append(asset)
else:
unmatched.append(asset)
return matched, unmatched
def compute_tag_match_score(
asset_id: str,
*,
script_tags: Iterable[Any],
tag_names_by_id: dict[str, Any] | None = None,
clip_ai_tags_by_asset: dict[str, list[dict]] | None = None,
) -> float:
"""计算单个素材的标签匹配综合得分(0.0 ~ 1.0).
综合得分 = sum(命中权重) / max(可能权重)
- AI 标签每命中一个 +2.0
- 素材标签每命中一个 +1.0
- max_possible = len(wanted) * (AI_TAG_WEIGHT + ASSET_TAG_WEIGHT)
Args:
asset_id: 素材 ID。
script_tags: 文案标签。
tag_names_by_id: 素材标签名索引。
clip_ai_tags_by_asset: AI 标签索引。
Returns:
归一化得分 0.0~1.0。
"""
wanted = _normalize_tags(script_tags)
if not wanted:
return 0.0
# AI 得分
ai_score = _compute_ai_score(asset_id, wanted, clip_ai_tags_by_asset)
# 素材标签得分
name_index = build_asset_tag_name_index(tag_names_by_id or {})
names = name_index.get(asset_id, set())
asset_score = len(names & wanted) * ASSET_TAG_WEIGHT
# 归一化:最大可能得分 = 文案标签数 × (AI权重 + 素材权重)
max_possible = len(wanted) * (AI_TAG_WEIGHT + ASSET_TAG_WEIGHT)
if max_possible <= 0:
return 0.0
return min((ai_score + asset_score) / max_possible, 1.0)
def pick_narrative_assets(
assets: list[Any],
*,
script_tags: Iterable[Any],
tag_names_by_id: dict[str, Any] | None = None,
clip_ai_tags_by_asset: dict[str, list[dict]] | None = None,
limit: int | None = None,
rng: Any = None,
) -> list[Any]:
@@ -100,9 +223,13 @@ def pick_narrative_assets(
smart_match.smart_select_assets(质量/时长/新鲜度/未使用 + 随机噪声),
不重写评分维度。
P2 增强:有 AI 标签的片段命中时权重更高(2.0 vs 1.0),
命中池内部按综合标签得分排序(AI 标签命中多的排前面)。
Args:
assets: ready 视频素材候选(调用方负责状态/类型过滤)。
script_tags / tag_names_by_id: 见 match_assets_by_script_tags。
clip_ai_tags_by_asset: #1970 P2 — {asset_id: [ai_tag_dict, ...]}。
limit: 需要的素材数量;None 表示全部(命中池 + 全部未命中池)。
rng: 注入 smart_select_assets 的随机源(可复现)。
@@ -115,6 +242,7 @@ def pick_narrative_assets(
assets,
script_tags=script_tags,
tag_names_by_id=tag_names_by_id,
clip_ai_tags_by_asset=clip_ai_tags_by_asset,
)
need = limit if (limit is not None and limit > 0) else None
+94
View File
@@ -37,6 +37,7 @@ class DoubaoClient:
self.base_url: str = settings.doubao_base_url.rstrip("/")
self.timeout: int = settings.doubao_timeout
self.max_retries: int = settings.doubao_max_retries
self.vision_model: str = settings.doubao_vision_model
@property
def is_available(self) -> bool:
@@ -103,6 +104,99 @@ class DoubaoClient:
logger.error("豆包API调用最终失败: %s", last_error)
return None
def vision_completion(
self,
messages: list[dict],
images: list[str] | None = None,
max_tokens: int = 2048,
temperature: float = 0.3,
timeout: int | None = None,
) -> Optional[str]:
"""调用豆包视觉理解 API(OpenAI 兼容多模态格式).
将 images 附加到最后一条 user message 的 content 中,
使用 vision_model(默认 doubao-1-5-vision-pro-250915)。
Args:
messages: 对话消息列表。最后一条 user message 会被注入图片内容。
images: 图片列表,支持 base64 data URI 或 HTTP(S) URL。
max_tokens: 最大生成 token 数,默认 2048。
temperature: 采样温度,默认 0.3(视觉任务偏低更稳定)。
timeout: 单次请求超时秒数,不传则使用默认 self.timeout。
Returns:
模型返回的文本内容,失败返回 None。
"""
if not self.is_available:
return None
# 构造多模态 content:先追加文本,再追加图片
vision_messages = []
for msg in messages:
vision_messages.append(dict(msg))
# 将图片注入最后一条 user message
if images and vision_messages:
# 找到最后一条 user message
for i in range(len(vision_messages) - 1, -1, -1):
if vision_messages[i].get("role") == "user":
text_content = vision_messages[i].get("content", "")
multi_content: list[dict[str, Any]] = []
if text_content:
multi_content.append({"type": "text", "text": text_content})
for img in images:
if img.startswith("data:") or img.startswith("http://") or img.startswith("https://"):
multi_content.append({"type": "image_url", "image_url": {"url": img}})
else:
# 当作 base64 编码
multi_content.append(
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}}
)
vision_messages[i]["content"] = multi_content
break
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload: dict[str, Any] = {
"model": self.vision_model,
"messages": vision_messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
req_timeout = timeout or self.timeout
last_error: Optional[Exception] = None
for attempt in range(self.max_retries + 1):
try:
response = httpx.post(
url,
headers=headers,
json=payload,
timeout=req_timeout,
)
response.raise_for_status()
data = response.json()
content = data["choices"][0]["message"]["content"]
return content.strip()
except Exception as e:
last_error = e
if attempt < self.max_retries:
wait = 0.5 * (2**attempt)
logger.warning(
"豆包视觉API调用失败,%.1fs后重试 (第%d/%d次): %s",
wait,
attempt + 1,
self.max_retries + 1,
e,
)
time.sleep(wait)
logger.error("豆包视觉API调用最终失败: %s", last_error)
return None
# ── 单例 ─────────────────────────────────────────────────────────────────────
+292
View File
@@ -0,0 +1,292 @@
"""#1970 P2 片段级 AI 标签模块测试。
测试范围:
- build_vision_prompt: 返回有效 prompt
- parse_vision_response: 正常/异常/空值
- tag_atom_clip: 成功/MediaKit不可用/视觉API失败/超时降级
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import UTC, datetime
import pytest
from packages.domain.atom_clip_tagger import (
build_vision_prompt,
parse_vision_response,
tag_atom_clip,
)
# ── Fake 对象 ──────────────────────────────────────────────────────────────
@dataclass
class FakeClip:
id: str = "clip-001"
asset_id: str = "asset-001"
start_time: float = 0.0
end_time: float = 5.0
duration: float = 5.0
clip_index: int = 0
tags: list[str] = field(default_factory=lambda: ["tag1", "tag2"])
ai_tags: dict | None = None
class FakeDoubaoClient:
"""模拟豆包客户端."""
def __init__(self, available: bool = True, response: str | None = None, raise_error: bool = False):
self._available = available
self._response = response
self._raise_error = raise_error
self.vision_calls: list[dict] = []
@property
def is_available(self) -> bool:
return self._available
def vision_completion(self, messages, images=None, timeout=None, **kwargs):
self.vision_calls.append({"messages": messages, "images": images, "timeout": timeout})
if self._raise_error:
raise RuntimeError("API error")
return self._response
class FakeMediaKitClient:
"""模拟 MediaKit 客户端."""
def __init__(self, available: bool = True, frames: list[dict] | None = None):
self._available = available
self._frames = frames
@property
def is_available(self) -> bool:
return self._available
def extract_frames(self, video_url, strategy=None, max_frames=None, **kwargs):
return self._frames
# ── build_vision_prompt ────────────────────────────────────────────────────
class TestBuildVisionPrompt:
def test_returns_non_empty_string(self):
prompt = build_vision_prompt()
assert isinstance(prompt, str)
assert len(prompt) > 100
def test_contains_required_keys(self):
prompt = build_vision_prompt()
assert "scene" in prompt
assert "objects" in prompt
assert "action" in prompt
assert "shot" in prompt
assert "has_text" in prompt
def test_requests_json_format(self):
prompt = build_vision_prompt()
assert "JSON" in prompt or "json" in prompt
# ── parse_vision_response ──────────────────────────────────────────────────
class TestParseVisionResponse:
def test_valid_json(self):
response = json.dumps(
{
"scene": ["工厂", "车间"],
"objects": ["产品", "机器"],
"action": ["演示"],
"shot": "特写",
"has_text": True,
}
)
result = parse_vision_response(response)
assert result["scene"] == ["工厂", "车间"]
assert result["objects"] == ["产品", "机器"]
assert result["action"] == ["演示"]
assert result["shot"] == "特写"
assert result["has_text"] is True
def test_json_with_markdown_code_block(self):
response = '```json\n{"scene": ["办公室"], "objects": ["电脑"], "action": ["说话"], "shot": "中景", "has_text": false}\n```'
result = parse_vision_response(response)
assert result["scene"] == ["办公室"]
assert result["has_text"] is False
def test_json_embedded_in_text(self):
response = '这是一些说明文字\n{"scene": ["户外"], "objects": ["汽车"], "action": ["展示"], "shot": "远景", "has_text": false}\n结束'
result = parse_vision_response(response)
assert result["scene"] == ["户外"]
def test_empty_response(self):
assert parse_vision_response("") == {}
assert parse_vision_response(None) == {}
assert parse_vision_response(" ") == {}
def test_invalid_json(self):
assert parse_vision_response("这不是JSON") == {}
def test_partial_fields(self):
response = json.dumps({"scene": ["工厂"]})
result = parse_vision_response(response)
assert result["scene"] == ["工厂"]
assert result["objects"] == []
assert result["shot"] == ""
assert result["has_text"] is False
def test_invalid_shot_value(self):
response = json.dumps({"scene": [], "objects": [], "action": [], "shot": "全景", "has_text": False})
result = parse_vision_response(response)
# "全景" 不在有效值 ("特写", "中景", "远景") 中
assert result["shot"] == ""
def test_string_values_converted_to_list(self):
response = json.dumps(
{"scene": "工厂", "objects": "产品", "action": "演示", "shot": "特写", "has_text": "true"}
)
result = parse_vision_response(response)
assert result["scene"] == ["工厂"]
assert result["objects"] == ["产品"]
assert result["has_text"] is True
def test_non_dict_json(self):
assert parse_vision_response("[1, 2, 3]") == {}
assert parse_vision_response('"hello"') == {}
# ── tag_atom_clip ──────────────────────────────────────────────────────────
class TestTagAtomClip:
def test_success_with_mediakit(self):
"""MediaKit 可用 + 视觉 API 成功 → 返回完整 AI 标签."""
clip = FakeClip()
fake_doubao = FakeDoubaoClient(
response=json.dumps(
{
"scene": ["工厂"],
"objects": ["产品"],
"action": ["演示"],
"shot": "特写",
"has_text": False,
}
)
)
fake_mediakit = FakeMediaKitClient(
frames=[
{"image_url": "https://example.com/frame1.jpg", "timestamp": 0.0},
{"image_url": "https://example.com/frame2.jpg", "timestamp": 2.5},
{"image_url": "https://example.com/frame3.jpg", "timestamp": 5.0},
]
)
result = tag_atom_clip(
clip=clip,
video_url="https://example.com/video.mp4",
doubao_client=fake_doubao,
mediakit_client=fake_mediakit,
)
assert result["scene"] == ["工厂"]
assert result["objects"] == ["产品"]
assert result["shot"] == "特写"
assert result["inherited_tags"] == ["tag1", "tag2"]
assert len(fake_doubao.vision_calls) == 1
def test_doubao_unavailable_returns_inherited(self):
"""DoubaoClient 不可用 → 返回 inherited_tags."""
clip = FakeClip()
fake_doubao = FakeDoubaoClient(available=False)
result = tag_atom_clip(
clip=clip,
video_url="https://example.com/video.mp4",
doubao_client=fake_doubao,
)
assert result == {"inherited_tags": ["tag1", "tag2"]}
assert len(fake_doubao.vision_calls) == 0
def test_mediakit_unavailable_no_ffmpeg(self):
"""MediaKit 不可用 + 无 ffmpeg → 降级 inherited_tags."""
clip = FakeClip()
fake_doubao = FakeDoubaoClient()
fake_mediakit = FakeMediaKitClient(available=False)
result = tag_atom_clip(
clip=clip,
video_url="https://example.com/video.mp4",
doubao_client=fake_doubao,
mediakit_client=fake_mediakit,
)
# 没有 ffmpeg 的情况下,帧提取失败
assert result == {"inherited_tags": ["tag1", "tag2"]}
def test_vision_api_error_returns_inherited(self):
"""视觉 API 抛异常 → 降级 inherited_tags."""
clip = FakeClip()
fake_doubao = FakeDoubaoClient(raise_error=True)
fake_mediakit = FakeMediaKitClient(frames=[{"image_url": "https://example.com/frame.jpg", "timestamp": 0.0}])
result = tag_atom_clip(
clip=clip,
video_url="https://example.com/video.mp4",
doubao_client=fake_doubao,
mediakit_client=fake_mediakit,
)
assert result == {"inherited_tags": ["tag1", "tag2"]}
def test_vision_api_empty_response(self):
"""视觉 API 返回空 → 降级 inherited_tags."""
clip = FakeClip()
fake_doubao = FakeDoubaoClient(response=None)
fake_mediakit = FakeMediaKitClient(frames=[{"image_url": "https://example.com/frame.jpg", "timestamp": 0.0}])
result = tag_atom_clip(
clip=clip,
video_url="https://example.com/video.mp4",
doubao_client=fake_doubao,
mediakit_client=fake_mediakit,
)
assert result == {"inherited_tags": ["tag1", "tag2"]}
def test_vision_api_invalid_json_response(self):
"""视觉 API 返回无效 JSON → 降级 inherited_tags."""
clip = FakeClip()
fake_doubao = FakeDoubaoClient(response="这不是JSON格式")
fake_mediakit = FakeMediaKitClient(frames=[{"image_url": "https://example.com/frame.jpg", "timestamp": 0.0}])
result = tag_atom_clip(
clip=clip,
video_url="https://example.com/video.mp4",
doubao_client=fake_doubao,
mediakit_client=fake_mediakit,
)
assert result == {"inherited_tags": ["tag1", "tag2"]}
def test_clip_with_empty_tags(self):
"""空素材标签 → inherited_tags 为空列表."""
clip = FakeClip(tags=[])
fake_doubao = FakeDoubaoClient(available=False)
result = tag_atom_clip(
clip=clip,
video_url="https://example.com/video.mp4",
doubao_client=fake_doubao,
)
assert result == {"inherited_tags": []}
if __name__ == "__main__":
pytest.main([__file__, "-q"])
+185
View File
@@ -0,0 +1,185 @@
"""#1970 hflip 放开(has_text 来自 atom_clip.ai_tags)端到端参数链路测试。
覆盖:
1. UnifiedRenderService 传入 clip_has_text 后微变换计划的翻转门控;
2. RenderAdapter._resolve_clip_has_text 按 atom_clip.ai_tags.has_text
解析布尔列表(显式 False 才可翻转,其余保守),失败回退 None;
3. 纯函数层在「混合有/无文字」列表下的行为(顺序对齐)。
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from video_processing.micro_transform_pure import build_micro_transform_plan
def _make_service(plan_config: dict | None = None, clip_has_text=None):
from video_processing.unified_render_service import UnifiedRenderService
svc = object.__new__(UnifiedRenderService)
svc.plan = MagicMock()
svc.plan.config = plan_config or {}
svc.plan.id = "plan-1"
svc.plan.clips = []
svc._micro_plan_cache = None
svc._micro_plan_loaded = False
svc._clip_has_text = clip_has_text
return svc
def _clip(clip_id: str, atom_clip_id: str = "", clip_type: str = "main"):
return SimpleNamespace(id=clip_id, atom_clip_id=atom_clip_id, clip_type=clip_type)
def _atom(clip_id: str, ai_tags):
return SimpleNamespace(id=clip_id, ai_tags=ai_tags)
class TestServiceClipHasText:
def test_none_stays_conservative(self):
# 未注入检测列表:所有片段一律不翻转
svc = _make_service({"generation_task_id": "t1"}, clip_has_text=None)
plan = svc._get_micro_transform_plan(30)
assert plan is not None
assert all(c.has_text for c in plan.clips)
assert all(not c.hflip for c in plan.clips)
def test_explicit_no_text_allows_hflip(self):
# AI 明确判定无文字:允许参与 50% 翻转(40 段应至少出现一些翻转)
svc = _make_service({"generation_task_id": "t-allow"}, clip_has_text=[False] * 40)
plan = svc._get_micro_transform_plan(40)
assert plan is not None
assert all(not c.has_text for c in plan.clips)
assert any(c.hflip for c in plan.clips)
assert all(not c.hflip or not c.has_text for c in plan.clips)
def test_all_text_never_flips(self):
svc = _make_service({"generation_task_id": "t-text"}, clip_has_text=[True] * 40)
plan = svc._get_micro_transform_plan(40)
assert all(c.has_text for c in plan.clips)
assert all(not c.hflip for c in plan.clips)
def test_mixed_order_alignment(self):
# 仅第 0、2 个片段无文字;has_text 标记必须与片段序号严格对齐
svc = _make_service({"generation_task_id": "t-mix"}, clip_has_text=[False, True, False, True])
plan = svc._get_micro_transform_plan(4)
assert [c.has_text for c in plan.clips] == [False, True, False, True]
assert all(not plan.clips[i].hflip for i in (1, 3))
for i in (0, 2):
# 无文字片段的翻转由 50% 种子决定,但允许翻转(不强制一定翻)
assert plan.clips[i].has_text is False
def test_list_shorter_than_clips_missing_are_conservative(self):
# 列表短于片段数:缺位片段按有文字处理
svc = _make_service({"generation_task_id": "t-short"}, clip_has_text=[False])
plan = svc._get_micro_transform_plan(3)
assert [c.has_text for c in plan.clips] == [False, True, True]
assert not plan.clips[1].hflip and not plan.clips[2].hflip
def test_plan_reproducible_with_real_list(self):
cfg = {"generation_task_id": "task-x", "video_index": 1}
flags = [False, True, False, False, True]
p1 = _make_service(cfg, clip_has_text=flags)._get_micro_transform_plan(5)
p2 = _make_service(dict(cfg), clip_has_text=list(flags))._get_micro_transform_plan(5)
assert [c.hflip for c in p1.clips] == [c.hflip for c in p2.clips]
class TestPureMixedFlags:
def test_pure_function_mixed_flags(self):
plan = build_micro_transform_plan("seed-1", 0, 4, clip_has_text=[False, True, False, True])
assert [c.has_text for c in plan.clips] == [False, True, False, True]
# 有文字片段绝不翻转
assert not plan.clips[1].hflip and not plan.clips[3].hflip
class TestResolveClipHasText:
def _adapter(self):
from video_processing.render_adapter import RenderAdapter
return RenderAdapter(MagicMock())
def test_no_atom_ids_returns_none(self):
adapter = self._adapter()
clips = [_clip("c1", ""), _clip("c2", "")]
assert adapter._resolve_clip_has_text(clips) is None
def test_explicit_false_only_maps_to_false(self):
adapter = self._adapter()
clips = [
_clip("c1", "a1"),
_clip("c2", "a2"),
_clip("c3", "a3"),
_clip("c4", "a4"),
_clip("c5", "a5"),
]
atoms = [
_atom("a1", {"has_text": False}), # 明确无文字 → False
_atom("a2", {"has_text": True}), # 有文字
_atom("a3", None), # 标签未生成
_atom("a4", {"scene": ["工厂"]}), # has_text 缺失(null
_atom("a5", {"has_text": "false"}), # 非布尔 → 保守
]
with patch(
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
return_value=atoms,
):
result = adapter._resolve_clip_has_text(clips)
assert result == [False, True, True, True, True]
def test_audio_clips_excluded_and_order_kept(self):
adapter = self._adapter()
clips = [
_clip("c1", "a1", clip_type="main"),
_clip("bgm", "", clip_type="audio"),
_clip("c2", "a2", clip_type="pip"),
]
atoms = [
_atom("a1", {"has_text": False}),
_atom("a2", {"has_text": False}),
]
with patch(
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
return_value=atoms,
) as mock_find:
result = adapter._resolve_clip_has_text(clips)
# 只查非 audio 片段的 atom id,且顺序为 main → pip
assert mock_find.call_args.args[0] == ["a1", "a2"]
assert result == [False, False]
def test_missing_atom_record_defaults_true(self):
adapter = self._adapter()
clips = [_clip("c1", "a1"), _clip("c2", "a2")]
with patch(
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
return_value=[_atom("a1", {"has_text": False})], # a2 查不到
):
result = adapter._resolve_clip_has_text(clips)
assert result == [False, True]
def test_query_failure_returns_none(self):
adapter = self._adapter()
clips = [_clip("c1", "a1")]
with patch(
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
side_effect=RuntimeError("db down"),
):
assert adapter._resolve_clip_has_text(clips) is None
def test_duplicate_atom_ids_queried_once(self):
adapter = self._adapter()
clips = [_clip("c1", "a1"), _clip("c2", "a1")]
with patch(
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
return_value=[_atom("a1", {"has_text": False})],
) as mock_find:
result = adapter._resolve_clip_has_text(clips)
assert mock_find.call_args.args[0] == ["a1"]
assert result == [False, False]
@@ -22,6 +22,7 @@ def _make_service(plan_config: dict | None = None, clips=None):
svc.plan.clips = clips or []
svc._micro_plan_cache = None
svc._micro_plan_loaded = False
svc._clip_has_text = None
return svc
@@ -159,6 +160,7 @@ class TestStreamCopyGate:
svc.clips = [source]
svc._micro_plan_cache = None
svc._micro_plan_loaded = False
svc._clip_has_text = None
resolved = ResolvedClip(
clip_id="c1",
asset_id="a1",
@@ -0,0 +1,306 @@
"""#1970 P2 叙事匹配 AI 标签加权测试。
测试范围:
- AI 标签命中时权重 2.0
- 无 AI 标签时降级到素材标签权重 1.0
- 混合场景(部分素材有 AI 标签,部分只有素材标签)
- compute_tag_match_score 归一化得分
"""
from __future__ import annotations
import datetime as dt
import random
from dataclasses import dataclass, field
import pytest
from packages.domain.narrative_match import (
AI_TAG_WEIGHT,
ASSET_TAG_WEIGHT,
_compute_ai_score,
_extract_ai_tag_names,
compute_tag_match_score,
match_assets_by_script_tags,
pick_narrative_assets,
)
@dataclass
class FakeAsset:
id: str
tag_ids: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
status: str = "ready"
file_type: str = "video"
duration: float = 10.0
quality_score: float | None = None
created_at: object = None
metadata: dict = field(default_factory=dict)
def _make_old_dt():
return dt.datetime(2020, 1, 1, tzinfo=dt.UTC)
# ── _extract_ai_tag_names ─────────────────────────────────────────────────
class TestExtractAiTagNames:
def test_extracts_all_keys(self):
ai_tags = {
"scene": ["工厂", "车间"],
"objects": ["产品"],
"action": ["演示"],
"shot": "特写", # shot 不参与标签匹配
"has_text": False,
}
names = _extract_ai_tag_names(ai_tags)
assert names == {"工厂", "车间", "产品", "演示"}
def test_empty_dict(self):
assert _extract_ai_tag_names({}) == set()
def test_none_values(self):
ai_tags = {"scene": None, "objects": None, "action": None}
assert _extract_ai_tag_names(ai_tags) == set()
def test_case_insensitive(self):
ai_tags = {"scene": ["Factory"], "objects": [], "action": []}
names = _extract_ai_tag_names(ai_tags)
assert "factory" in names
# ── _compute_ai_score ─────────────────────────────────────────────────────
class TestComputeAiScore:
def test_single_clip_hit(self):
wanted = {"工厂", "演示"}
clips = [{"scene": ["工厂"], "objects": [], "action": ["演示"]}]
score = _compute_ai_score("a1", wanted, {"a1": clips})
# 命中 2 个 × 2.0 = 4.0
assert score == 2 * AI_TAG_WEIGHT
def test_multiple_clips_takes_best(self):
wanted = {"工厂", "演示"}
clips = [
{"scene": ["工厂"], "objects": [], "action": []}, # 1 hit = 2.0
{"scene": ["工厂"], "objects": [], "action": ["演示"]}, # 2 hits = 4.0
]
score = _compute_ai_score("a1", wanted, {"a1": clips})
assert score == 2 * AI_TAG_WEIGHT # best = 2 hits
def test_no_match(self):
wanted = {"美食"}
clips = [{"scene": ["工厂"], "objects": [], "action": ["演示"]}]
score = _compute_ai_score("a1", wanted, {"a1": clips})
assert score == 0.0
def test_no_clips_for_asset(self):
wanted = {"工厂"}
assert _compute_ai_score("a1", wanted, {}) == 0.0
assert _compute_ai_score("a1", wanted, None) == 0.0
def test_empty_wanted(self):
clips = [{"scene": ["工厂"], "objects": [], "action": []}]
assert _compute_ai_score("a1", set(), {"a1": clips}) == 0.0
# ── match_assets_by_script_tags with AI tags ──────────────────────────────
class TestMatchWithAiTags:
def test_ai_tag_hit_puts_in_matched(self):
"""有 AI 标签命中 → 进入命中池."""
assets = [FakeAsset("a1", created_at=_make_old_dt())]
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
matched, unmatched = match_assets_by_script_tags(
assets,
script_tags=["工厂"],
clip_ai_tags_by_asset=clip_ai_tags,
)
assert [a.id for a in matched] == ["a1"]
assert unmatched == []
def test_ai_tag_no_match_puts_in_unmatched(self):
"""AI 标签未命中 → 进入未命中池."""
assets = [FakeAsset("a1", created_at=_make_old_dt())]
clip_ai_tags = {"a1": [{"scene": ["办公室"], "objects": [], "action": []}]}
matched, unmatched = match_assets_by_script_tags(
assets,
script_tags=["工厂"],
clip_ai_tags_by_asset=clip_ai_tags,
)
assert matched == []
assert [a.id for a in unmatched] == ["a1"]
def test_asset_tag_still_works_without_ai_tags(self):
"""无 AI 标签时,素材标签仍按权重 1.0 匹配."""
assets = [FakeAsset("a1", tags=["工厂"], created_at=_make_old_dt())]
matched, unmatched = match_assets_by_script_tags(
assets,
script_tags=["工厂"],
)
assert [a.id for a in matched] == ["a1"]
def test_mixed_ai_and_asset_tags(self):
"""混合场景:一个素材有 AI 标签,另一个只有素材标签."""
assets = [
FakeAsset("a1", created_at=_make_old_dt()), # AI 标签命中
FakeAsset("a2", tags=["工厂"], created_at=_make_old_dt()), # 素材标签命中
FakeAsset("a3", tags=["美食"], created_at=_make_old_dt()), # 无命中
]
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
matched, unmatched = match_assets_by_script_tags(
assets,
script_tags=["工厂"],
clip_ai_tags_by_asset=clip_ai_tags,
)
assert {a.id for a in matched} == {"a1", "a2"}
assert [a.id for a in unmatched] == ["a3"]
def test_ai_tag_and_asset_tag_both_hit(self):
"""同一素材 AI 标签和素材标签都命中 → 仍在命中池."""
assets = [FakeAsset("a1", tags=["工厂"], created_at=_make_old_dt())]
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
matched, unmatched = match_assets_by_script_tags(
assets,
script_tags=["工厂"],
tag_names_by_id={"a1": ["工厂"]},
clip_ai_tags_by_asset=clip_ai_tags,
)
assert [a.id for a in matched] == ["a1"]
# ── compute_tag_match_score ───────────────────────────────────────────────
class TestComputeTagMatchScore:
def test_ai_only_score(self):
"""仅 AI 标签命中."""
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": ["演示"]}]}
score = compute_tag_match_score(
"a1",
script_tags=["工厂", "演示"],
clip_ai_tags_by_asset=clip_ai_tags,
)
# AI: 2 hits × 2.0 = 4.0; asset: 0; max = 2 × 3.0 = 6.0
assert abs(score - 4.0 / 6.0) < 0.01
def test_asset_only_score(self):
"""仅素材标签命中."""
score = compute_tag_match_score(
"a1",
script_tags=["工厂", "演示"],
tag_names_by_id={"a1": ["工厂"]},
)
# AI: 0; asset: 1 hit × 1.0 = 1.0; max = 2 × 3.0 = 6.0
assert abs(score - 1.0 / 6.0) < 0.01
def test_both_ai_and_asset_score(self):
"""AI 标签 + 素材标签同时命中."""
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
score = compute_tag_match_score(
"a1",
script_tags=["工厂", "演示"],
tag_names_by_id={"a1": ["工厂"]},
clip_ai_tags_by_asset=clip_ai_tags,
)
# AI: 1 hit × 2.0 = 2.0; asset: 1 hit × 1.0 = 1.0; max = 2 × 3.0 = 6.0
assert abs(score - 3.0 / 6.0) < 0.01
def test_no_match_score_zero(self):
"""无命中 → 得分 0."""
score = compute_tag_match_score(
"a1",
script_tags=["工厂"],
tag_names_by_id={"a1": ["美食"]},
)
assert score == 0.0
def test_full_match_score_one(self):
"""全命中 → 得分接近 1.0."""
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}]}
score = compute_tag_match_score(
"a1",
script_tags=["工厂", "产品", "演示"],
clip_ai_tags_by_asset=clip_ai_tags,
)
# AI: 3 hits × 2.0 = 6.0; max = 3 × 3.0 = 9.0 → 6/9 = 0.667
# 注意:仅 AI 标签命中不可能达到 1.0(因为 max 包含素材权重)
assert score > 0.5
def test_empty_script_tags(self):
"""空文案标签 → 得分 0."""
assert compute_tag_match_score("a1", script_tags=[]) == 0.0
# ── pick_narrative_assets with AI tags ────────────────────────────────────
class TestPickNarrativeWithAiTags:
def _assets(self):
old = _make_old_dt()
return [
FakeAsset("ai_match", created_at=old), # AI 标签命中
FakeAsset("asset_match", tags=["工厂"], created_at=old), # 素材标签命中
FakeAsset("no_match", tags=["美食"], created_at=old), # 无命中
]
def test_ai_match_prioritized(self):
"""AI 标签命中的素材进入命中池."""
clip_ai_tags = {"ai_match": [{"scene": ["工厂"], "objects": [], "action": []}]}
picked = pick_narrative_assets(
self._assets(),
script_tags=["工厂"],
clip_ai_tags_by_asset=clip_ai_tags,
limit=2,
rng=random.Random(0),
)
ids = {a.id for a in picked}
assert "ai_match" in ids
assert "asset_match" in ids
def test_fallback_when_no_ai_match(self):
"""AI 标签和素材标签都未命中 → 降级."""
clip_ai_tags = {"ai_match": [{"scene": ["办公室"], "objects": [], "action": []}]}
picked = pick_narrative_assets(
self._assets(),
script_tags=["不存在"],
clip_ai_tags_by_asset=clip_ai_tags,
limit=2,
rng=random.Random(0),
)
assert len(picked) == 2 # 从全量中选取
def test_backward_compat_without_ai_tags(self):
"""不传 clip_ai_tags_by_asset 时行为与之前完全一致."""
picked = pick_narrative_assets(
self._assets(),
script_tags=["工厂"],
limit=2,
rng=random.Random(0),
)
# 仅素材标签匹配
ids = {a.id for a in picked}
assert "asset_match" in ids
if __name__ == "__main__":
pytest.main([__file__, "-q"])