Compare commits

..

7 Commits

Author SHA1 Message Date
xiaoxia ca7f875224 fix(dedup): scope=user跨项目查重不做时长预过滤,同源不同时长视频可互相检出 (#1702) (#1708)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 57s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 58s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m35s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 2m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
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 1m32s
CI/CD Pipeline / Integration Tests (push) Successful in 1m50s
CI/CD Pipeline / Validate - Style (push) Successful in 2m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m5s
CI/CD Pipeline / Validate - Security (push) Successful in 4m47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m38s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m38s
CI/CD Pipeline / Unit Tests (push) Successful in 7m43s
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-05 10:12:50 +08:00
xiaoxia 1c5060c224 test(#1677): E2E 适配新 5 步流程(批量生成改造) (#1707)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / 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 / Check push changed paths (push) Successful in 6s
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 / PR Build Web Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
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 Staging API Image (push) Successful in 44s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m47s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 1m58s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m58s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m53s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m39s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Validate - Security (push) Successful in 4m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m7s
CI/CD Pipeline / Unit Tests (push) Successful in 8m47s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m21s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 09:50:35 +08:00
xiaoxia 15909a92e5 fix(dedup): check_duplicate支持exclude_video_id,修复recompute自匹配 (#1702) (#1706)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) 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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 50s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (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 API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m11s
AI Code Review / AI Code Review (pull_request) Successful in 1m58s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m45s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m18s
CI/CD Pipeline / Validate - Style (push) Successful in 2m57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m38s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / Validate - Security (push) Successful in 4m29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m29s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m3s
CI/CD Pipeline / Unit Tests (push) Successful in 10m33s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
2026-09-05 09:38:17 +08:00
xiaoxia af25045123 feat(#1677): 多视频批量生成前端 — 数量弹窗/批量预览/独立标题配音封面/5步流程 (#1704)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
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 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m51s
CI/CD Pipeline / Validate - Style (push) Successful in 2m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 4m27s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m49s
CI/CD Pipeline / Unit Tests (push) Successful in 9m32s
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
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14m36s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 22s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m15s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m23s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m44s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 3m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m50s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 09:03:39 +08:00
xiaoxia 7a4aa27f71 fix(dedup): recompute任务从file_url派生OSS下载key,修复重算404 (#1702) (#1705)
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 Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 22s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
CI/CD Pipeline / Build Staging API Image (push) Successful in 22s
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m57s
AI Code Review / AI Code Review (pull_request) Successful in 2m24s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Validate - Style (push) Successful in 3m13s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m33s
CI/CD Pipeline / Validate - Security (push) Successful in 5m31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m27s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m10s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m42s
CI/CD Pipeline / Unit Tests (push) Successful in 10m32s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / 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-05 08:36:19 +08:00
xiaoxia 28b3010668 fix(dedup): 修复查重率恒为0%——指纹绕开降重裁剪+局部片段复用+阈值校准+3个单位bug (#1702) (#1703)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 7s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 18s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build 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 / Build Staging Worker Image (push) Successful in 43s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 8s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
AI Code Review / AI Code Review (pull_request) Failing after 2m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Style (push) Successful in 3m13s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Validate - Security (push) Successful in 5m58s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m38s
CI/CD Pipeline / Unit Tests (push) Successful in 8m43s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m35s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 07:57:23 +08:00
xiaoxia c1763b995c feat(#1677): 多视频批量生成后端补全 — 批量预览变体数组 + 按变体独立标题/配音/封面 (#1701)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m23s
CI/CD Pipeline / Integration Tests (push) Successful in 2m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / Validate - Style (push) Successful in 2m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
AI Code Review / AI Code Review (pull_request) Successful in 6m27s
CI/CD Pipeline / Validate - Security (push) Successful in 6m50s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m4s
CI/CD Pipeline / Unit Tests (push) Successful in 8m48s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 00:44:24 +08:00
47 changed files with 4147 additions and 1370 deletions
+1 -1
View File
@@ -1460,6 +1460,7 @@ jobs:
- validate-security
- validate-python
- unit-tests
- frontend-lint
- frontend-unit-test
if: github.event_name == 'push' && github.ref_name == 'main' && !failure() && !cancelled()
strategy:
@@ -2116,4 +2117,3 @@ jobs:
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# CI retry trigger
-1
View File
@@ -1 +0,0 @@
retrigger3
-1
View File
@@ -263,4 +263,3 @@ pytest --cov=packages --cov-report=html
---
**License**: MIT
<!-- CI trigger: 1788229339 -->
+239 -124
View File
@@ -23,6 +23,7 @@ from app.dependencies import (
get_generation_task_repository,
)
from app.schemas.generation_task import (
BatchPreviewGenerationTaskResponse,
CreatePreviewGenerationTaskRequest,
PreviewGenerationTaskResponse,
)
@@ -193,11 +194,19 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
if started_at and completed_at:
generate_duration = (completed_at - started_at).total_seconds()
title_cfg = getattr(task, "title_config", None)
title_cfg = title_cfg if isinstance(title_cfg, dict) else {}
extra_meta = getattr(task, "extra_meta", None)
extra_meta = extra_meta if isinstance(extra_meta, dict) else {}
voice_library_id = getattr(task, "voice_library_id", "") or ""
if not isinstance(voice_library_id, str):
voice_library_id = str(voice_library_id) if voice_library_id else ""
return PreviewGenerationTaskResponse(
task_id=task.id,
status=task.status.value if hasattr(task.status, "value") else str(task.status),
progress=float(task.progress or 0.0),
is_preview=bool(getattr(task, "is_preview", True)),
variant_index=int(extra_meta.get("variant_index", 0) or 0),
resolution=getattr(task, "resolution", "") or "",
video_url=video_url,
duration=duration,
@@ -206,6 +215,8 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
transition_count=transition_count,
material_usage=material_usage,
error_message=task.error_message or "",
title_text=str(title_cfg.get("text", "") or ""),
voice_library_id=voice_library_id,
created_at=task.created_at,
started_at=started_at,
finished_at=completed_at,
@@ -213,45 +224,95 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
)
@router.post("/preview", response_model=PreviewGenerationTaskResponse, status_code=201)
def _resolve_preview_edit_plan_id(
*,
request: CreatePreviewGenerationTaskRequest,
task,
db: Session,
user_id: str,
) -> str:
"""确定任务关联的编辑计划ID:优先前端传入,否则按 template_id+user 兜底查找。"""
if task.source_edit_plan_id:
return task.source_edit_plan_id
if not request.template_id:
return ""
try:
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
_plan_repo = SQLAlchemyEditPlanRepository(db)
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
for _p in _plans:
if (_p.created_by_user_id or "") == user_id:
logger.info(
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_p.id,
)
return _p.id
except Exception:
logger.warning(
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
return ""
def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
"""从变体数组中取值:长度1=共用,长度>N=按索引,空数组=回退 fallback。"""
if not values:
return fallback
if len(values) == 1:
return values[0]
return values[index] if index < len(values) else fallback
@router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, status_code=201)
def create_preview_generation_task(
request: CreatePreviewGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository=Depends(get_generation_task_repository),
db: Session = Depends(get_db_session),
asset_repo=Depends(get_asset_repository),
) -> PreviewGenerationTaskResponse:
"""创建预览生成任务。
) -> BatchPreviewGenerationTaskResponse:
"""创建预览生成任务(支持批量)
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset),确认生成时可直接复用预览产物。
Args:
request: 预览任务创建请求(template_id + asset_ids 等)
preview_count=1 时行为与旧版完全一致(创建 1 个任务);
preview_count=N 时一次创建 N 个独立变体任务:
- 每个变体克隆独立编辑计划(独立 clips、独立随机素材起点),N 个预览内容互不相同
- 每个变体拥有独立 task_id / 状态 / 预览视频 URL,前端按 task_id 分别轮询
- 标题样式(font/color/position 等)全局共用;标题文字/配音/封面可按变体独立
titles[] / voice_library_ids[] / cover_urls[],长度1=共用,长度N=独立)
Returns:
201 + 预览任务详情
201 + 变体任务数组 {items: [...], total: N}
"""
user_id = authenticated_user.user.id
count = max(1, request.preview_count)
logger.info(
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
user_id,
request.template_id,
len(request.asset_ids),
request.preview_count,
count,
)
# 预检查队列限流
# 预检查队列限流(按变体总数计)
try:
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending + 1 > USER_PENDING_LIMIT:
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending + 1, limit=USER_PENDING_LIMIT)
if global_pending + 1 > GLOBAL_PENDING_LIMIT:
raise GlobalQueueFull(pending_count=global_pending + 1, limit=GLOBAL_PENDING_LIMIT)
if user_pending + count > USER_PENDING_LIMIT:
raise UserPendingLimitExceeded(
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
)
if global_pending + count > GLOBAL_PENDING_LIMIT:
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
except UserPendingLimitExceeded as e:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count}),请等待后再提交",
) from e
except GlobalQueueFull as e:
raise HTTPException(
@@ -273,14 +334,11 @@ def create_preview_generation_task(
w, h = int(parts[0]), int(parts[1])
base = 1920
if w < h:
# 竖屏
output_width = round(base * w / h)
output_height = base
else:
# 横屏
output_width = base
output_height = round(base * h / w)
# 对齐到偶数
output_width = output_width - output_width % 2
output_height = output_height - output_height % 2
except (ValueError, ZeroDivisionError):
@@ -289,42 +347,71 @@ def create_preview_generation_task(
logger.info(
"[预览生成] 分辨率: video_ratio=%s%s (%dx%d)",
video_ratio, resolution, output_width, output_height,
video_ratio,
resolution,
output_width,
output_height,
)
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
title_config = request.title_config or {}
base_title_config = request.title_config or {}
use_case = CreateGenerationTaskUseCase(generation_task_repository)
# ── 预创建第一个任务,仅用于解析源编辑计划(不落库为最终任务)──
# 先创建一个临时任务拿到 task 对象上下文,实际 N 个任务在循环中统一创建;
# 为保持与旧版一致的源 plan 解析逻辑,先创建任务0、解析源 plan,
# 再预克隆 N 个变体 plan,最后重建任务关联。
# 简化实现:直接创建全部任务,plan 关联在创建后、入队前完成。
created_tasks: list = []
variant_plan_ids: list[str] = [] # 每个变体最终关联的 plan_id(按变体顺序)
try:
task = use_case.execute(
CreateGenerationTaskCommand(
project_id="",
asset_library_id="",
strategy_id=strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=list(request.asset_ids),
title_ids=list(request.title_ids),
voice_ids=list(request.voice_ids),
created_by_user_id=user_id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode="",
batch_id="",
video_title=request.video_title,
resolution=resolution,
bgm_config=request.bgm_config or {},
auto_retry_enabled=False,
auto_retry_max=0,
is_preview=True,
title_config=title_config,
output_width=output_width,
output_height=output_height,
for variant_index in range(count):
# 变体独立标题文字:titles[] 覆盖 title_config.text
variant_title_text = _variant_value(request.titles, variant_index, "")
variant_title_config = dict(base_title_config)
if variant_title_text.strip():
variant_title_config["text"] = variant_title_text.strip()
# 变体独立配音
variant_voice_library_id = _variant_value(
request.voice_library_ids, variant_index, request.voice_library_id
)
)
task = use_case.execute(
CreateGenerationTaskCommand(
project_id="",
asset_library_id="",
strategy_id=strategy_id,
voice_library_id=variant_voice_library_id,
template_id=request.template_id,
asset_ids=list(request.asset_ids),
title_ids=list(request.title_ids),
voice_ids=list(request.voice_ids),
created_by_user_id=user_id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode="",
batch_id="",
video_title=request.video_title,
resolution=resolution,
bgm_config=request.bgm_config or {},
auto_retry_enabled=False,
auto_retry_max=0,
is_preview=True,
title_config=variant_title_config,
output_width=output_width,
output_height=output_height,
)
)
task.extra_meta["variant_index"] = variant_index
# 解析源编辑计划(前端传入或按模板兜底查找)
source_plan_id = _resolve_preview_edit_plan_id(request=request, task=task, db=db, user_id=user_id)
task.source_edit_plan_id = source_plan_id
generation_task_repository.update(task)
created_tasks.append(task)
except ValueError as e:
logger.warning("[预览生成] 创建失败: %s", e)
raise HTTPException(status_code=400, detail=str(e)) from e
@@ -332,93 +419,121 @@ def create_preview_generation_task(
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
# 关联编辑计划:如果前端未传 source_edit_plan_id,通过 template_id + user_id 查找
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
_plan_repo = SQLAlchemyEditPlanRepository(db)
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
for _p in _plans:
if (_p.created_by_user_id or "") == user_id:
task.source_edit_plan_id = _p.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_p.id,
)
break
except Exception:
logger.warning(
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# 每条预览都关联独立克隆 plan:多预览前端为 N 次并发调用,若共用同一 plan
# 则 N 条预览片段完全相同;克隆时片段起点按持久化历史区间重算(含受控复用),
# 保证各预览版本内容不同
if task.source_edit_plan_id:
# ── 克隆独立变体 plan:N 个预览全部克隆(预览不污染源 plan)──
# 源 plan 不存在(无编辑历史)时各任务走自身随机选片流程,不克隆。
source_plan_id = created_tasks[0].source_edit_plan_id if created_tasks else ""
if source_plan_id:
try:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
_preview_plan = _plan_svc.clone_plan_for_variant(
task.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix="预览变体",
)
task.source_edit_plan_id = _preview_plan.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 预览关联独立克隆 plan: task_id=%s clone_plan_id=%s",
task.id,
_preview_plan.id,
)
except Exception as clone_err:
# 不退回共用原 plan(否则多条预览内容相同,违反去重诉求):
# 标记任务失败并中断,前端可重新发起预览
logger.error(
"[预览生成] 克隆预览变体 plan 失败,任务标记失败: task_id=%s error=%s",
task.id,
clone_err,
exc_info=True,
)
_mark_task_failed(generation_task_repository, task, "预览变体计划创建失败")
for variant_index in range(count):
last_err: Exception | None = None
variant_plan = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant_plan = _plan_svc.clone_plan_for_variant(
source_plan_id,
created_by_user_id=user_id,
name_suffix=f"预览变体{variant_index + 1}" if count > 1 else "预览变体",
)
break
except Exception as clone_err: # noqa: PERF203
last_err = clone_err
logger.warning(
"[预览生成] 克隆变体 plan 失败(尝试%d/2): variant=%d error=%s",
_attempt + 1,
variant_index,
clone_err,
exc_info=True,
)
if variant_plan is None:
logger.error(
"[预览生成] 克隆预览变体 plan 重试仍失败: variant=%d source=%s",
variant_index,
source_plan_id,
exc_info=last_err,
)
# 标记已创建任务失败
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
raise HTTPException(
status_code=500,
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant_plan.id)
except HTTPException:
raise
except Exception as e:
logger.error("[预览生成] 克隆变体 plan 异常: %s", e, exc_info=True)
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
raise HTTPException(
status_code=500,
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
) from clone_err
) from e
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
try:
if not safe_enqueue_generation_task(
task,
generation_task_repository,
user_id=user_id,
log_prefix="[预览生成]",
log_task_status=True,
):
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队失败")
raise HTTPException(status_code=500, detail="任务入队失败,请稍后重试")
except UserPendingLimitExceeded as e:
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
) from None
except GlobalQueueFull:
_mark_task_failed(generation_task_repository, task, "系统队列已满")
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
# 关联变体 plan 并回写标题配置
for variant_index, task in enumerate(created_tasks):
if variant_plan_ids:
task.source_edit_plan_id = variant_plan_ids[variant_index]
generation_task_repository.update(task)
# 回写变体标题到 plan configworker 渲染时从 plan 读取 title 配置)
if task.source_edit_plan_id and (task.title_config or {}).get("text", "").strip():
try:
from app.api.routes.generation_tasks import _writeback_edit_plan_config
return _to_preview_response(task)
_writeback_edit_plan_config(
plan_id=task.source_edit_plan_id,
task_id=task.id,
title_config=task.title_config,
db=db,
)
except Exception:
logger.warning(
"[预览生成] 回写标题配置失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# ── 入队 ──
responses: list[PreviewGenerationTaskResponse] = []
for variant_index, task in enumerate(created_tasks):
try:
enqueued = safe_enqueue_generation_task(
task,
generation_task_repository,
user_id=user_id,
log_prefix=f"[预览生成][变体{variant_index + 1}]",
log_task_status=True,
)
if not enqueued:
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队失败")
except UserPendingLimitExceeded:
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
except GlobalQueueFull:
_mark_task_failed(generation_task_repository, task, "系统队列已满")
except Exception:
logger.exception("[预览生成] 入队异常: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队异常")
# enqueue 会原地更新 task 状态/进度,直接用 task 构造响应
responses.append(_to_preview_response(task))
# 队列满/限流时若全部失败,返回明确错误码
if all(r.status == "failed" for r in responses):
first_err = next((r.error_message for r in responses if r.error_message), "")
if "待处理任务" in first_err:
raise HTTPException(status_code=429, detail=first_err or "待处理任务超限")
if "队列" in first_err:
raise HTTPException(status_code=503, detail=first_err or "系统繁忙,请稍后再试")
logger.info(
"[预览生成] 创建完成: %d 个变体任务, task_ids=%s",
len(responses),
[r.task_id for r in responses],
)
return BatchPreviewGenerationTaskResponse(items=responses, total=len(responses))
@router.get("/preview/{task_id}", response_model=PreviewGenerationTaskResponse)
+26 -6
View File
@@ -47,6 +47,15 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
"""从变体数组中取值:长度1=共用,长度>N=按索引,空数组=回退 fallback。"""
if not values:
return fallback
if len(values) == 1:
return values[0]
return values[index] if index < len(values) else fallback
def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse(
id=task.id,
@@ -472,12 +481,21 @@ def create_generation_task(
if task_index > 0 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index - 1]
# 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[]
# 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段
variant_title_text = _variant_value(request.titles, task_index, "")
variant_title_config = dict(request.title_config or {})
if variant_title_text.strip():
variant_title_config["text"] = variant_title_text.strip()
variant_voice_library_id = _variant_value(request.voice_library_ids, task_index, request.voice_library_id)
variant_cover_url = _variant_value(request.cover_urls, task_index, request.cover_url)
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=effective_strategy_id,
voice_library_id=request.voice_library_id,
voice_library_id=variant_voice_library_id,
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
@@ -495,10 +513,12 @@ def create_generation_task(
source_task_id=request.source_task_id,
output_width=request.output_width,
output_height=request.output_height,
cover_url=request.cover_url,
title_config=request.title_config or {},
cover_url=variant_cover_url,
title_config=variant_title_config,
)
)
# 变体序号写入 extra_meta(响应/排查时可辨识)
task.extra_meta["variant_index"] = task_index
try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
@@ -533,13 +553,13 @@ def create_generation_task(
# 回写 plan.config:必须在 enqueue 之前执行,
# 确保 worker 读取 plan 时 config 中已包含 generation_task_id。
# 只在首个任务时回写一次,避免批量生成时循环覆盖
# 批量场景下每个变体关联独立 plan,需各自回写自己的变体标题配置
_effective_plan_id = task.source_edit_plan_id
if _effective_plan_id and len(created_tasks) == 0:
if _effective_plan_id:
_writeback_edit_plan_config(
plan_id=_effective_plan_id,
task_id=task.id,
title_config=request.title_config,
title_config=variant_title_config,
db=db,
)
+7 -3
View File
@@ -252,6 +252,10 @@ class RecomputeDedupRequest(BaseModel):
None,
description="指定视频 ID 列表。为空则对当前用户所有缺少查重数据的视频重新计算。",
)
force: bool = Field(
False,
description="强制重算:即使视频已有查重数据也重新入队(#1702 查重算法升级后用于存量视频重算)。",
)
class RecomputeDedupResponse(BaseModel):
@@ -291,15 +295,15 @@ def recompute_dedup(
skipped = 0
for video in target_videos:
# 已有完整查重数据的跳过
if video.duplicate_rate is not None and video.video_fingerprint:
# 已有完整查重数据的跳过force=True 时强制重算,#1702 算法升级后存量视频需要重算指纹/分片)
if not request.force and video.duplicate_rate is not None and video.video_fingerprint:
skipped += 1
continue
# 触发异步查重任务
celery_app.send_task("worker.check_duplicate", args=[video.id])
enqueued += 1
logger.info("Enqueued re-dedup for video %s (user=%s)", video.id, user_id)
logger.info("Enqueued re-dedup for video %s (user=%s, force=%s)", video.id, user_id, request.force)
return RecomputeDedupResponse(
enqueued=enqueued,
+69 -2
View File
@@ -25,6 +25,12 @@ class CreateGenerationTaskRequest(BaseModel):
asset_library_id: str = ""
strategy_id: str = ""
voice_library_id: str = ""
# ── 多变体独立配音(批量生成)──
# 长度 1 = 所有变体共用;长度 = count = 每个变体独立配音;空数组 = 回退 voice_library_id
voice_library_ids: list[str] = Field(
default_factory=list,
description="各变体独立配音素材库ID数组:长度1=共用,长度=count=独立。为空时回退 voice_library_id",
)
created_by_user_id: str = ""
# ── 模板模式新增字段 ──
template_id: str = ""
@@ -75,6 +81,27 @@ class CreateGenerationTaskRequest(BaseModel):
output_width: int = Field(default=1280, description="输出视频宽度")
output_height: int = Field(default=720, description="输出视频高度")
cover_url: str = Field(default="", description="封面图片 URL")
# ── 多变体独立封面(批量生成)──
# 长度 1 = 所有变体共用;长度 = count = 每个变体独立封面;空数组 = 回退 cover_url
cover_urls: list[str] = Field(
default_factory=list,
description="各变体独立封面URL数组:长度1=共用,长度=count=独立。为空时回退 cover_url",
)
# ── 多变体独立标题文字(批量生成)──
# 长度 1 = 所有变体共用;长度 = count = 每个变体独立标题文字;空数组 = 使用 title_config.text
titles: list[str] = Field(
default_factory=list,
description="各变体独立标题文字数组:长度1=共用,长度=count=独立。为空时使用 title_config.text",
)
@model_validator(mode="after")
def _check_variant_arrays(self) -> "CreateGenerationTaskRequest":
"""变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = count(独立)。"""
for name in ("voice_library_ids", "cover_urls", "titles"):
arr = getattr(self, name)
if arr and len(arr) != 1 and len(arr) != self.count:
raise ValueError(f"{name} 长度必须为 1(共用)或 {self.count}(与 count 一致),当前为 {len(arr)}")
return self
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -185,8 +212,33 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
)
title_config: dict = Field(
default_factory=dict,
description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow。N个变体时样式全局共用",
)
# ── 多变体独立配置(preview_count > 1)──
# 长度 1 = 所有变体共用;长度 = preview_count = 每个变体独立;空数组 = 回退单值字段
titles: list[str] = Field(
default_factory=list,
description="各变体独立标题文字数组:长度1=共用,长度=preview_count=独立。为空时使用 title_config.text",
)
voice_library_ids: list[str] = Field(
default_factory=list,
description="各变体独立配音素材库ID数组:长度1=共用,长度=preview_count=独立。为空时回退 voice_library_id",
)
cover_urls: list[str] = Field(
default_factory=list,
description="各变体独立封面URL数组:长度1=共用,长度=preview_count=独立(预览阶段通常为空)",
)
@model_validator(mode="after")
def _check_variant_arrays(self) -> "CreatePreviewGenerationTaskRequest":
"""变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = preview_count(独立)。"""
for name in ("titles", "voice_library_ids", "cover_urls"):
arr = getattr(self, name)
if arr and len(arr) != 1 and len(arr) != self.preview_count:
raise ValueError(
f"{name} 长度必须为 1(共用)或 {self.preview_count}(与 preview_count 一致),当前为 {len(arr)}"
)
return self
@model_validator(mode="after")
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
@@ -202,7 +254,7 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
class PreviewGenerationTaskResponse(BaseModel):
"""预览生成任务响应。
"""单个预览变体任务响应。
包含任务状态、进度、分辨率、生成结果 URL 等关键字段。
"""
@@ -211,6 +263,7 @@ class PreviewGenerationTaskResponse(BaseModel):
status: str
progress: float
is_preview: bool = True
variant_index: int = 0
resolution: str = ""
video_url: str = ""
duration: float = 0.0
@@ -219,7 +272,21 @@ class PreviewGenerationTaskResponse(BaseModel):
transition_count: int = 0
material_usage: dict = Field(default_factory=dict)
error_message: str = ""
title_text: str = ""
voice_library_id: str = ""
created_at: datetime | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
generate_duration: float = 0.0
class BatchPreviewGenerationTaskResponse(BaseModel):
"""批量预览任务响应:preview_count=N 时返回 N 个独立变体任务。
- items: 变体任务数组,按 variant_index 顺序排列,每个含独立 task_id/状态/预览视频URL
- total: 变体总数(= preview_count
- 前端按 items[i].task_id 分别轮询 GET /preview/{task_id} 获取进度与结果
"""
items: list[PreviewGenerationTaskResponse]
total: int
+3 -25
View File
@@ -255,32 +255,10 @@ test.describe("Core generation flow", () => {
expect(genData.items.length).toBeGreaterThan(0)
expect(genData.items[0].id).toBeTruthy()
// Step 5: 确认生成页 — 任务创建成功后自动跳转,展示渲染进度
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible({
timeout: 15_000,
// 单视频(N=1):点击「确认生成视频」后直接跳 Step 5 封面(与旧流程一致)
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 180_000,
})
// Step 5 → Step 6:等待渲染终态
// - 完成:页面出现「视频生成完成」,步骤5「下一步」按钮解锁,点击进入封面
// - 失败:出现「生成失败」,停在确认生成页也算向导流程走通
// - 超时未终态(测试环境 worker 可能不处理任务):进度仍在轮询,同样算走通
const renderSucceeded = await page
.getByText("视频生成完成", { exact: false })
.waitFor({ timeout: 180_000 })
.then(() => true)
.catch(() => false)
if (renderSucceeded) {
// 渲染完成:手动点「下一步」进入封面步骤(渲染完不自动跳转)
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: 封面(最后一步,无主按钮),仅验证页面渲染
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 15_000,
})
} else {
// 失败或超时:仍在确认生成页(进度展示或失败提示),向导流程已完整走通
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
console.log("[E2E] 渲染任务失败或未在 180s 内完成,冒烟测试仍通过(已达确认生成页)")
}
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
// 创建失败时停留在标题页并展示错误提示
+25 -4
View File
@@ -33,15 +33,36 @@ export interface CreatePreviewRequest {
preset_id?: string
volume?: number
}
/** 批量预览数量(1~10),默认1。N>1 时返回 N 个独立变体任务 */
preview_count?: number
/** 各变体独立标题文字:长度1=共用,长度=preview_count=独立,空数组=使用 title_config.text */
titles?: string[]
/** 各变体独立配音素材库ID:长度1=共用,长度=preview_count=独立,空数组=回退 voice_library_id */
voice_library_ids?: string[]
/** 各变体独立封面URL:长度1=共用,长度=preview_count=独立(预览阶段通常为空) */
cover_urls?: string[]
}
/** 创建预览任务响应 */
export interface CreatePreviewResponse {
/** 单个预览变体任务 */
export interface PreviewVariantItem {
task_id: string
status: PreviewStatus
status: string
progress: number
is_preview: boolean
variant_index: number
resolution: string
created_at: string
video_url: string
duration: number
error_message: string
title_text: string
voice_library_id: string
created_at?: string | null
}
/** 创建预览任务响应(单变体,preview_count=1 时 items 长度为1 */
export interface CreatePreviewResponse {
items: PreviewVariantItem[]
total: number
/** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id */
source_edit_plan_id?: string
}
+8
View File
@@ -92,6 +92,14 @@ export interface CreateGenerationTaskRequest {
preset_id?: string
volume?: number
}
/** 批量生成数量(1~10),默认1。不传=单条旧逻辑 */
count?: number
/** 各变体独立标题文字:长度1=共用,长度=count=独立,空数组=使用 title_config/custom_title */
titles?: string[]
/** 各变体独立配音素材库ID:长度1=共用,长度=count=独立,空数组=回退 voice_library_id */
voice_library_ids?: string[]
/** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */
cover_urls?: string[]
}
/** 单个生成任务详情(对齐后端 GenerationTaskResponse */
+367 -62
View File
@@ -1,12 +1,11 @@
/**
* 智能剪辑页面 — 前端实时预览架构
* 6 步向导:选择模板 → 素材 → 配音 → 标题(预览) → 确认生成 → 选择封面
* 智能剪辑页面Issue #1677 多视频批量生成)
* 5 步向导:选择模板(弹数量) → 素材 → 配音 → 标题(预览+确认生成) → 封面
*
* 架构:
* - 步骤 4 右侧显示 FrontendPreviewPlayer 实时预览
* - 步骤 5 右侧内联播放生成中的/最终视频
* - 步骤 6 封面从最终成片中智能选帧(MediaKit)
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
* - N=1:前端 Canvas 实时预览(FrontendPreviewPlayer),零回归
* - N>1:服务器批量预览(POST /generation/preview?preview_count=N),
* N 个变体分别轮询,网格展示、独立可播放、CSS 标题浮层实时叠加、勾选批量生成
*/
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { message } from "antd"
@@ -17,16 +16,20 @@ import { useCloneProgress } from "@/hooks/useCloneProgress"
import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader"
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
import ServerPreviewGrid from "./components/ServerPreviewGrid"
import PreviewCountModal from "./components/PreviewCountModal"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent"
import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { useBatchPreview } from "./hooks/useBatchPreview"
import { usePreviewAssets } from "./hooks/usePreviewAssets"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
import { calculateResolution } from "./utils/calculateResolution"
import "./generate.css"
const GeneratePage: React.FC = () => {
@@ -53,10 +56,8 @@ const GeneratePage: React.FC = () => {
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen,
setCloneModalOpen,
videoRatio,
@@ -72,8 +73,51 @@ const GeneratePage: React.FC = () => {
setStoredSourceEditPlanId,
serverClips,
setServerClips,
previewCount,
setPreviewCount,
previewTitles,
setPreviewTitles,
voiceModePerVideo,
setVoiceModePerVideo,
voiceLibraryIds,
setVoiceLibraryIds,
previewCovers,
setPreviewCovers,
selectedVariantIds,
setSelectedVariantIds,
} = formState
const isBatch = previewCount > 1
/* ── 配音选择同步:共用配音 ↔ 变体数组 ── */
// 触发场景:①共用配音变化 ②批量模式进入/退出 ③独立→共用切换(需把所有变体刷成共用配音)
// 独立模式下:仅同步变体[0](其选择器绑定共用配音),用户单独选择的其他变体不覆盖
const prevVoiceSyncRef = useRef({
voice: selectedVoice,
batch: isBatch,
perVideo: voiceModePerVideo,
})
useEffect(() => {
const prev = prevVoiceSyncRef.current
const voiceChanged = prev.voice !== selectedVoice
const modeChanged = prev.batch !== isBatch || prev.perVideo !== voiceModePerVideo
prevVoiceSyncRef.current = { voice: selectedVoice, batch: isBatch, perVideo: voiceModePerVideo }
if (!voiceChanged && !modeChanged) return
if (!isBatch) return
if (!voiceModePerVideo) {
// 共用模式(含刚从独立切回):所有变体跟随共用配音,未选择的补默认值
setVoiceLibraryIds((prevIds) => (prevIds || []).map((id) => id || selectedVoice))
} else if (voiceChanged) {
// 独立模式下共用配音变化:仅同步变体[0](与共用选择器绑定),其余不覆盖
setVoiceLibraryIds((prevIds) =>
(prevIds || []).map((id, i) => (i === 0 ? selectedVoice : id)),
)
}
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
/* ── 数量选择弹窗 ── */
const [countModalOpen, setCountModalOpen] = useState(false)
/* ── 标题样式回调 ── */
const styleUpdaters = useTitleStyleUpdaters({
titleSettings,
@@ -127,7 +171,7 @@ const GeneratePage: React.FC = () => {
}, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials])
/* ── 克隆声音 ── */
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
const { addClone } = useCloneProgress()
const handleCloneSuccess = (voice: VoiceClone) => {
addClone(voice)
@@ -156,19 +200,95 @@ const GeneratePage: React.FC = () => {
[bgm, currentTemplate],
)
/* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验 ── */
/* ── 加载素材详情(供前端预览播放器使用) ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets(
previewAssetIds,
previewAssetsEnabled,
)
/* ── 预览就绪:素材已加载,且有模板 ── */
const previewReady = useMemo(
/* ── 预览就绪 ── */
const singlePreviewReady = useMemo(
() => previewAssetsReady && !!currentTemplate,
[previewAssetsReady, currentTemplate],
)
/* ── 批量服务器预览(N>1) ── */
const buildPreviewRequest = useCallback(() => {
const { width, height } = calculateResolution(videoRatio || "9:16")
const voiceLibraryId =
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
return {
template_id: selectedTemplate,
asset_ids: previewAssetIds,
output_width: width,
output_height: height,
video_ratio: videoRatio,
voice_library_id: voiceLibraryId,
...(voiceModePerVideo && voiceLibraryIds.some(Boolean)
? { voice_library_ids: voiceLibraryIds.map((id) => id || voiceLibraryId) }
: {}),
preview_count: previewCount,
// 批量预览不传 titles/title_config:标题文字与样式由前端 CSS 浮层实时叠加
// (用户改标题/样式即时可见,无需重渲染);正式生成时才把标题烧录进成片
duration: duration || undefined,
bgm_config: {
enabled: bgm !== false,
...(bgmConfig?.music_id ? { preset_id: bgmConfig.music_id } : {}),
},
...(storedSourceEditPlanId || sourceEditPlanId
? { source_edit_plan_id: storedSourceEditPlanId || sourceEditPlanId || undefined }
: {}),
}
}, [
videoRatio,
voiceMode,
selectedClonedVoice,
selectedVoice,
selectedTemplate,
previewAssetIds,
voiceModePerVideo,
voiceLibraryIds,
previewCount,
duration,
bgm,
bgmConfig,
storedSourceEditPlanId,
sourceEditPlanId,
])
const {
variants,
status: batchPreviewStatus,
progress: batchPreviewProgress,
failedCount: batchFailedCount,
trigger: retryBatchPreview,
} = useBatchPreview({
enabled: isBatch && currentStep >= 4 && previewAssetIds.length > 0 && !!selectedTemplate,
buildRequest: buildPreviewRequest,
onPreviewTasksCreated: (_taskIds, planId) => {
if (planId) setStoredSourceEditPlanId(planId)
},
})
/** 批量预览就绪:全部变体渲染完成 */
const batchPreviewReady =
isBatch && variants.length > 0 && variants.every((v) => v.status === "ready")
/** 步骤4整体预览就绪状态 */
const previewReady = isBatch ? batchPreviewReady : singlePreviewReady
/* ── 勾选变体 ── */
const toggleVariantSelect = useCallback(
(index: number) => {
setSelectedVariantIds((prev) => {
const list = prev || []
return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
})
},
[setSelectedVariantIds],
)
/* ── 视频生成核心逻辑 ── */
const {
generating,
@@ -199,16 +319,61 @@ const GeneratePage: React.FC = () => {
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
previewTaskId,
bgmConfig,
previewCount,
variantTitles: previewTitles,
variantVoiceLibraryIds: voiceLibraryIds,
voiceModePerVideo,
variantCoverUrls: previewCovers,
selectedVariantIndexes: isBatch ? selectedVariantIds : undefined,
onGenerationSuccess: () => {
setPreviewTaskId(null)
setStoredSourceEditPlanId(null)
},
})
/* ── 步骤4「确认生成视频」:校验标题/预览 → 创建最终渲染任务 → 成功后进入步骤5 ── */
/* ── 数量弹窗确认:设置数量 + 同步批量数组长度 + 进入步骤2 ── */
const handleCountConfirm = useCallback(
(count: number) => {
setPreviewCount(count)
setCountModalOpen(false)
// 同步批量数组长度
setPreviewTitles((prev) => {
const list = prev || []
const base = list[0] || titleSettings.title || ""
return Array.from({ length: count }, (_, i) => list[i] ?? (i === 0 ? base : ""))
})
setVoiceLibraryIds((prev) => {
const list = prev || []
return Array.from({ length: count }, (_, i) => list[i] ?? selectedVoice ?? "")
})
setPreviewCovers((prev) => {
const list = prev || []
return Array.from({ length: count }, (_, i) => list[i] ?? "")
})
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
setCurrentStep(2)
},
[
setPreviewCount,
setPreviewTitles,
setVoiceLibraryIds,
setPreviewCovers,
setSelectedVariantIds,
setCurrentStep,
titleSettings.title,
selectedVoice,
],
)
/* ── 步骤4「确认生成视频」 ── */
const handleConfirmGenerate = useCallback(async () => {
if (!titleSettings.title.trim()) {
message.warning("请选择或输入标题")
// 标题校验
if (previewTitles.some((t) => !t?.trim())) {
message.warning("请为每个视频输入标题")
return
}
if (isBatch && selectedVariantIds.length === 0) {
message.warning("请至少勾选一个视频")
return
}
if (!previewReady) {
@@ -216,10 +381,18 @@ const GeneratePage: React.FC = () => {
return
}
const ok = await handleGenerate()
if (ok) {
if (ok && !isBatch) {
setCurrentStep(5)
}
}, [titleSettings.title, previewReady, handleGenerate, setCurrentStep])
// 批量模式停留在步骤4,右侧网格显示生成进度,完成后点"下一步"进封面
}, [
isBatch,
selectedVariantIds.length,
previewReady,
previewTitles,
handleGenerate,
setCurrentStep,
])
/* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({
@@ -232,11 +405,24 @@ const GeneratePage: React.FC = () => {
titleSettings,
previewReady,
generated,
previewTitles,
selectedCount: isBatch ? selectedVariantIds.length : 1,
onOpenCountModal: () => setCountModalOpen(true),
})
/* ── 最终成片(步骤5/6 右侧播放) ── */
/* ── 最终成片(单视频右侧播放) ── */
const finalVideo = generatedVideos[0]
/** 批量生成进度文案 */
const batchGeneratingText = useMemo(() => {
if (batchPreviewStatus === "loading")
return `AI 正在渲染 ${previewCount} 个预览视频… ${batchPreviewProgress}%`
if (batchPreviewStatus === "failed") return "预览渲染失败,请重试"
if (batchPreviewStatus === "partial_failed")
return `${batchFailedCount} 个预览失败,可重新生成或勾选成功的视频`
return ""
}, [batchPreviewStatus, batchPreviewProgress, batchFailedCount, previewCount])
/* ================================================================
渲染
================================================================ */
@@ -247,8 +433,142 @@ const GeneratePage: React.FC = () => {
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
<div className={`xx-generate-layout${currentStep < 4 ? " full-width" : ""}`}>
{/* ════ 左侧:表单区 ════ */}
<div
className={`xx-generate-layout${currentStep < 4 ? " full-width" : ""}${
currentStep === 4 ? " step4-layout" : ""
}`}
>
{/* ════ 步骤4:左侧预览大区域 ════ */}
{currentStep === 4 && !!currentTemplate && (
<div className="xx-generate-preview-col">
{!isBatch ? (
/* 单视频:前端 Canvas 实时预览(与旧版一致) */
<FrontendPreviewPlayer
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
ready={previewAssets.length > 0}
serverClips={serverClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
titleSettings={{
title: titleSettings.title,
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
onTitlePositionChange={styleUpdaters.updateTitlePosition}
/>
) : (
/* 批量:服务器预览网格 */
<div className="xx-form-section">
<div className="xx-preview-header">
<h3>🎬 {previewCount} </h3>
{batchPreviewStatus === "loading" && (
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
{batchPreviewProgress}%
</span>
)}
{(batchPreviewStatus === "failed" || batchPreviewStatus === "partial_failed") && (
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={retryBatchPreview}
>
🔄
</button>
)}
</div>
{batchGeneratingText && (
<div
style={{
fontSize: 13,
color:
batchPreviewStatus === "failed"
? "var(--error-color, #ef4444)"
: "var(--text-secondary, #666)",
marginBottom: 12,
}}
>
{batchGeneratingText}
</div>
)}
<ServerPreviewGrid
variants={variants}
titles={previewTitles}
titleStyle={{
position: titleSettings.position,
color: titleSettings.color,
size: titleSettings.size,
}}
selectedIds={selectedVariantIds}
onToggleSelect={toggleVariantSelect}
selectable={!generating}
/>
{/* 生成中进度(批量) */}
{generating && (
<div className="xx-gen-progress-card" style={{ marginTop: 16 }}>
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{selectedVariantIds.length} {Math.round(progress)}
%
</div>
<div className="xx-gen-progress-sub">
</div>
</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card" style={{ marginTop: 16 }}>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title"></div>
<div className="xx-gen-error-msg">{generateError}</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={handleRetryGenerate}
>
🔄
</button>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card" style={{ marginTop: 16 }}>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title"> </div>
<div className="xx-gen-success-sub">
{generatedVideos.length}
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 封面 ════ */}
<div className="xx-generate-form">
<GenerateStepContent
currentStep={currentStep}
@@ -280,15 +600,6 @@ const GeneratePage: React.FC = () => {
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
voiceMode={voiceMode}
onVoiceModeChange={setVoiceMode}
selectedClonedVoice={selectedClonedVoice}
onSelectedClonedVoiceChange={setSelectedClonedVoice}
clonedVoices={clonedVoices}
addClone={addClone}
hasProcessing={hasProcessing}
cloneModalOpen={cloneModalOpen}
onCloneModalOpenChange={setCloneModalOpen}
generating={generating}
generated={generated}
generateError={generateError}
@@ -296,7 +607,16 @@ const GeneratePage: React.FC = () => {
generatedVideos={generatedVideos}
onRetry={handleRetryGenerate}
onDismissError={handleDismissError}
presetVoices={presetVoices}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={setPreviewTitles}
voiceModePerVideo={voiceModePerVideo}
onVoiceModePerVideoChange={setVoiceModePerVideo}
voiceLibraryIds={voiceLibraryIds}
onVoiceLibraryIdsChange={setVoiceLibraryIds}
previewCovers={previewCovers}
onPreviewCoversChange={setPreviewCovers}
selectedVariantIds={selectedVariantIds}
/>
<GenerateStepActions
@@ -307,43 +627,20 @@ const GeneratePage: React.FC = () => {
generating={generating}
generated={generated}
generateError={generateError}
selectedCount={isBatch ? selectedVariantIds.length : 1}
/>
</div>
{/* ════ 右侧:步骤4实时预览,步骤5/6最终视频 ════ */}
<div className="xx-generate-right-col">
{currentStep === 4 && !!currentTemplate && (
<FrontendPreviewPlayer
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
ready={previewAssets.length > 0}
serverClips={serverClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
titleSettings={{
title: titleSettings.title,
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
onTitlePositionChange={styleUpdaters.updateTitlePosition}
/>
)}
{currentStep >= 5 && generated && finalVideo && (
{/* ════ 步骤5(封面):成片播放器(单视频 ════ */}
{currentStep === 5 && !isBatch && generated && finalVideo && (
<div className="xx-generate-right-col">
<div className="xx-inline-video-player">
<video
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay={currentStep === 5}
autoPlay
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
poster={finalVideo.thumbnail_url || undefined}
poster={finalVideo.thumbnail_url}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
@@ -360,10 +657,18 @@ const GeneratePage: React.FC = () => {
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* 数量选择弹窗 */}
<PreviewCountModal
open={countModalOpen}
defaultCount={1}
onConfirm={handleCountConfirm}
onCancel={() => setCountModalOpen(false)}
/>
{/* 音色克隆弹窗 */}
<CloneModal
open={cloneModalOpen}
@@ -1,10 +1,10 @@
/**
* GeneratePage 步骤底部操作按钮
* GeneratePage 步骤底部操作按钮Issue #1677 改造后 5 步)
*
* 步骤 1~3:上一步 / 下一步
* 步骤 4(标题+预览):上一步 / 确认生成视频(点击后直接创建最终渲染任务,成功后跳转步骤5)
* 步骤 5(确认生成):上一步 / 下一步(渲染中禁用,渲染完成后可进入封面
* 步骤 6(选择封面):仅上一步
* 步骤 4(标题+预览+确认生成):确认生成按钮在右侧边栏底部(含勾选数量),
* 渲染中显示进度;生成完成后显示"下一步 → 选择封面"
* 步骤 5(选择封面):仅上一步
*/
import React from "react"
@@ -17,9 +17,11 @@ export interface GenerateStepActionsProps {
generating: boolean
generated: boolean
generateError: string | null
/** 批量模式下勾选的视频数量(N=1 时为1) */
selectedCount?: number
}
export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
currentStep,
onPrev,
onNext,
@@ -27,9 +29,10 @@ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
generating,
generated,
generateError,
selectedCount = 1,
}) => {
const renderPrimaryButton = () => {
/* 步骤 1~3:上一步 / 下一步(必填校验由 useStepNavigation.goNext 统一处理) */
/* 步骤 1~3:上一步 / 下一步 */
if (currentStep < 4) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
@@ -38,7 +41,7 @@ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
)
}
/* 步骤 4:确认生成视频(触发按钮在标题页) */
/* 步骤 4标题+预览+确认生成 */
if (currentStep === 4) {
if (generating) {
return (
@@ -57,31 +60,18 @@ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
if (generated) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
</button>
)
}
return (
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
{selectedCount > 1 ? `✨ 确认生成 ${selectedCount} 个视频` : "✨ 确认生成视频"}
</button>
)
}
/* 步骤 5:渲染中禁用,完成后下一步进入封面 */
if (currentStep === 5) {
return (
<button
className="xx-btn xx-btn-primary"
onClick={onNext}
disabled={generating || !generated}
>
{generating ? "视频生成中…" : "下一步 →"}
</button>
)
}
/* 步骤 6(最后一步):无主按钮 */
/* 步骤 5(封面,最后一步):无主按钮 */
return null
}
@@ -1,19 +1,16 @@
/**
* GeneratePage 步骤内容渲染
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
* 步骤顺序(5步,Issue #1677):模板(1) → 素材(2) → 配音(3) → 标题+预览+确认生成(4) → 封面(5)
*/
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import type { EditPlanClip } from "@/api/template-editor"
import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import type { CoverConfig } from "../types/cover"
import type { TitleSettings } from "../types"
import Step1TemplateSelect from "../components/Step1TemplateSelect"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
import Step3VoiceSelect from "../components/Step5VoiceSelect"
import Step3VoiceWithMode from "./Step3VoiceWithMode"
import Step4TitleSettings from "../components/Step4TitleSettings"
import Step5ConfirmGenerate from "../components/Step7ConfirmGenerate"
import Step6CoverSettings from "../components/Step6CoverSettings"
import type { GeneratedVideo } from "@/api/template-editor"
@@ -50,15 +47,6 @@ export interface GenerateStepContentProps {
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
onServerClipsChange: (clips: EditPlanClip[]) => void
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
onSelectedClonedVoiceChange: (id: string) => void
clonedVoices: VoiceClone[]
addClone: (voice: VoiceClone) => void
hasProcessing: boolean
cloneModalOpen: boolean
onCloneModalOpenChange: (open: boolean) => void
/* 生成 */
generating: boolean
generated: boolean
@@ -67,12 +55,22 @@ export interface GenerateStepContentProps {
generatedVideos: GeneratedVideo[]
onRetry: () => void
onDismissError: () => void
/* 其他 */
presetVoices: PresetVoiceItem[]
/** BGM 开关 */
bgm: boolean
/** BGM 配置(来自模板) */
bgmConfig?: { enabled: boolean; music_id?: string }
/* ── 批量生成(#1677)── */
previewCount: number
previewTitles: string[]
onPreviewTitlesChange: (titles: string[]) => void
voiceModePerVideo: boolean
onVoiceModePerVideoChange: (v: boolean) => void
voiceLibraryIds: string[]
onVoiceLibraryIdsChange: (ids: string[]) => void
previewCovers: string[]
onPreviewCoversChange: (urls: string[]) => void
/** 批量模式勾选的变体索引(封面卡片按勾选顺序展示) */
selectedVariantIds?: number[]
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -104,17 +102,17 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedVoice,
onSelectedVoiceChange,
onServerClipsChange,
voiceMode,
selectedClonedVoice,
clonedVoices,
generating,
generated,
generateError,
progress,
generatedVideos,
onRetry,
onDismissError,
presetVoices,
previewCount,
previewTitles,
onPreviewTitlesChange,
voiceModePerVideo,
onVoiceModePerVideoChange,
voiceLibraryIds,
onVoiceLibraryIdsChange,
previewCovers,
onPreviewCoversChange,
selectedVariantIds,
} = props
/* 当前模板的 segments,传给 Step2 构建 clips */
@@ -146,9 +144,14 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
)
case 3:
return (
<Step3VoiceSelect
<Step3VoiceWithMode
previewCount={previewCount}
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
voiceModePerVideo={voiceModePerVideo}
onVoiceModePerVideoChange={onVoiceModePerVideoChange}
voiceLibraryIds={voiceLibraryIds}
onVoiceLibraryIdsChange={onVoiceLibraryIdsChange}
/>
)
case 4:
@@ -167,33 +170,12 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onApplyPreset={onApplyPreset}
activePreset={activePreset}
titlePresets={titlePresets}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
/>
)
case 5:
return (
<Step5ConfirmGenerate
templates={userTemplates}
selectedTemplate={selectedTemplate}
materialMode={materialMode}
selectedMaterials={selectedMaterials}
smartSelectedIds={smartSelectedIds}
title={titleSettings.title}
voiceMode={voiceMode}
selectedVoice={selectedVoice}
selectedClonedVoice={selectedClonedVoice}
presetVoices={presetVoices}
clonedVoices={clonedVoices}
coverSettings={coverSettings}
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
onRetry={onRetry}
onDismissError={onDismissError}
/>
)
case 6:
return (
<Step6CoverSettings
coverSettings={coverSettings}
@@ -201,6 +183,11 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedTemplate={selectedTemplate}
titleSettings={titleSettings}
generatedVideos={generatedVideos}
previewCount={previewCount}
previewTitles={previewTitles}
previewCovers={previewCovers}
onPreviewCoversChange={onPreviewCoversChange}
selectedVariantIndexes={selectedVariantIds}
/>
)
default:
@@ -0,0 +1,127 @@
/**
* 生成数量选择弹窗(Issue #1677
* Step1 选完模板点「下一步」时弹出:要生成几个视频?(1~10)
* 默认 1,回车 = 1(零额外操作)
*/
import React, { useState, useEffect, useRef } from "react"
import { MAX_PREVIEW_COUNT } from "../constants"
interface PreviewCountModalProps {
open: boolean
/** 默认值(上次选择,默认1) */
defaultCount?: number
onConfirm: (count: number) => void
onCancel: () => void
}
const PreviewCountModal: React.FC<PreviewCountModalProps> = ({
open,
defaultCount = 1,
onConfirm,
onCancel,
}) => {
const [count, setCount] = useState(defaultCount)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setCount(defaultCount)
// 弹窗打开后聚焦并选中,方便直接回车=默认1
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, defaultCount])
const clamp = (n: number) => Math.max(1, Math.min(MAX_PREVIEW_COUNT, n || 1))
const handleConfirm = () => {
onConfirm(clamp(count))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
handleConfirm()
}
if (e.key === "Escape") {
onCancel()
}
}
if (!open) return null
return (
<div className="xx-modal-mask" onClick={onCancel}>
<div className="xx-modal-box xx-count-modal" onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: "0 0 8px", fontSize: 18 }}></h3>
<p style={{ margin: "0 0 20px", fontSize: 13, color: "var(--text-secondary, #666)" }}>
AI
</p>
<div className="xx-count-selector">
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c - 1))}
disabled={count <= 1}
aria-label="减少"
>
</button>
<input
ref={inputRef}
type="number"
min={1}
max={MAX_PREVIEW_COUNT}
value={count}
onChange={(e) => setCount(clamp(parseInt(e.target.value, 10) || 1))}
onKeyDown={handleKeyDown}
className="xx-count-input"
/>
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c + 1))}
disabled={count >= MAX_PREVIEW_COUNT}
aria-label="增加"
>
+
</button>
</div>
<div className="xx-count-quick">
{[1, 3, 5, 10].map((n) => (
<button
key={n}
type="button"
className={`xx-count-chip ${count === n ? "active" : ""}`}
onClick={() => setCount(n)}
>
{n}
</button>
))}
</div>
<div className="xx-count-actions">
<button type="button" className="xx-btn xx-btn-ghost" onClick={onCancel}>
</button>
<button type="button" className="xx-btn xx-btn-primary" onClick={handleConfirm}>
{count === 1 ? "生成 1 个视频" : `生成 ${count} 个视频`}
</button>
</div>
<p
style={{
margin: "12px 0 0",
fontSize: 12,
color: "var(--text-tertiary, #999)",
textAlign: "center",
}}
>
= 1
</p>
</div>
</div>
)
}
export default PreviewCountModal
@@ -0,0 +1,127 @@
/**
* 批量预览网格(Issue #1677
* N 个服务器渲染的预览视频,网格排列、各自独立播放、CSS 标题浮层实时叠加、勾选框批量选择
*/
import React from "react"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { VariantPreview } from "../hooks/useBatchPreview"
interface ServerPreviewGridProps {
variants: VariantPreview[]
/** 每个变体的标题文字(实时叠加浮层) */
titles: string[]
/** 标题样式(全局共用) */
titleStyle: {
position: string
color: string
size: number
}
/** 勾选的变体索引 */
selectedIds: number[]
onToggleSelect: (index: number) => void
/** 是否显示勾选框(确认生成前) */
selectable?: boolean
}
const ServerPreviewGrid: React.FC<ServerPreviewGridProps> = ({
variants,
titles,
titleStyle,
selectedIds,
onToggleSelect,
selectable = true,
}) => {
if (variants.length === 0) return null
return (
<div className="xx-variant-grid">
{variants.map((v) => {
const selected = selectedIds.includes(v.index)
const titleText = titles[v.index] || ""
return (
<div
key={v.index}
className={`xx-variant-card ${selected ? "selected" : ""} ${
v.status === "failed" ? "failed" : ""
}`}
onClick={() => {
if (selectable && v.status === "ready") onToggleSelect(v.index)
}}
role="button"
tabIndex={0}
>
{/* 勾选框 */}
{selectable && v.status === "ready" && (
<div className={`xx-variant-check ${selected ? "checked" : ""}`}>
{selected && "✓"}
</div>
)}
{/* 变体序号 */}
<div className="xx-variant-index"> {v.index + 1}</div>
{/* 视频区域 */}
<div className="xx-variant-video-wrap">
{v.status === "loading" && (
<div className="xx-variant-loading">
<LoadingOutlined style={{ fontSize: 28, color: "#3b82f6" }} />
<div className="xx-variant-progress">
<div
className="xx-variant-progress-bar"
style={{ width: `${Math.min(v.progress, 100)}%` }}
/>
</div>
<span className="xx-variant-progress-text">{v.progress}%</span>
</div>
)}
{v.status === "failed" && (
<div className="xx-variant-failed">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
<span>{v.error || "预览失败"}</span>
</div>
)}
{v.status === "ready" && v.videoUrl && (
<>
<video
src={v.videoUrl}
controls
style={{ width: "100%", display: "block", background: "#000", borderRadius: 8 }}
onClick={(e) => e.stopPropagation()}
/>
{/* 标题浮层(CSS 实时叠加,改标题即时可见) */}
{titleText && (
<div
className={`xx-variant-title-overlay pos-${titleStyle.position}`}
style={{
color: titleStyle.color,
fontSize: Math.max(13, Math.round(titleStyle.size * 0.55)),
WebkitTextStroke: "0.5px rgba(0,0,0,0.6)",
}}
onClick={(e) => e.stopPropagation()}
>
{titleText}
</div>
)}
</>
)}
</div>
{/* 底部状态 */}
<div className="xx-variant-footer">
{v.status === "ready" && selected && (
<span className="xx-variant-ready-tag">
<CheckCircleFilled style={{ color: "#52c41a" }} />
</span>
)}
{v.status === "ready" && !selected && selectable && (
<span className="xx-variant-skip-tag">/</span>
)}
</div>
</div>
)
})}
</div>
)
}
export default ServerPreviewGrid
@@ -0,0 +1,102 @@
/**
* Step3 配音选择(Issue #1677 批量生成)
* - 单视频 / 共用模式:与原配音选择完全一致
* - 独立模式(开关开启):N 个配音选择器,每个视频独立选择
*/
import React from "react"
import Step3VoiceSelect from "./Step5VoiceSelect"
interface Step3VoiceWithModeProps {
previewCount: number
/** 共用配音ID */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
/** 是否独立配音 */
voiceModePerVideo: boolean
onVoiceModePerVideoChange: (v: boolean) => void
/** 各变体独立配音ID */
voiceLibraryIds: string[]
onVoiceLibraryIdsChange: (ids: string[]) => void
}
const Step3VoiceWithMode: React.FC<Step3VoiceWithModeProps> = ({
previewCount,
selectedVoice,
onSelectedVoiceChange,
voiceModePerVideo,
onVoiceModePerVideoChange,
voiceLibraryIds,
onVoiceLibraryIdsChange,
}) => {
const isBatch = previewCount > 1
if (!isBatch) {
return (
<Step3VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
/>
)
}
return (
<div className="xx-form-section">
{/* 共用/独立切换 */}
<div className="xx-title-ai-toggle" style={{ marginBottom: 16 }}>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>
🎙 {voiceModePerVideo ? "每个视频独立配音" : "所有视频共用配音"}
</div>
<div style={{ fontSize: 12, color: "var(--text-tertiary, #999)", marginTop: 2 }}>
{voiceModePerVideo
? `${previewCount} 个视频分别选择不同配音`
: "所有视频使用同一个配音(默认)"}
</div>
</div>
<div
className={`xx-switch ${voiceModePerVideo ? "active" : ""}`}
onClick={() => onVoiceModePerVideoChange(!voiceModePerVideo)}
role="switch"
aria-checked={voiceModePerVideo}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onVoiceModePerVideoChange(!voiceModePerVideo)
}
}}
>
<div className="xx-switch-knob" />
</div>
</div>
{!voiceModePerVideo ? (
<Step3VoiceSelect
heading="🎙️ 共用配音"
description={`所有 ${previewCount} 个视频使用同一个配音,点击卡片可预览播放`}
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
/>
) : (
<div className="xx-per-voice-list">
{Array.from({ length: previewCount }, (_, i) => (
<Step3VoiceSelect
key={i}
heading={`🎙️ 视频 ${i + 1} 的配音`}
description="为这个视频单独选择配音"
compact
selectedVoice={voiceLibraryIds[i] || ""}
onSelectedVoiceChange={(id) => {
const next = [...voiceLibraryIds]
next[i] = id
onVoiceLibraryIdsChange(next)
}}
/>
))}
</div>
)}
</div>
)
}
export default Step3VoiceWithMode
@@ -1,12 +1,13 @@
/**
* Step 4 选择标题(合并原 Step4 标题输入 + Step5 标题样式面板
* Step 4 选择标题(Issue #1677 批量生成改造
*
* 左侧:标题文字输入 + AI生成标题 + 样式设置(位置/字号/字体/颜色/样式/预设)
* 右侧:FrontendPreviewPlayer 实时预览(由 GeneratePage 统一渲染)
* 布局(由 GeneratePage 编排):左侧大区域预览,右侧边栏标题设置。
* 本组件渲染在右侧边栏:
* - 标题文字:1 个视频 1 个输入框;N 个视频 N 个输入框各自独立
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
*/
import React from "react"
import { AutoComplete } from "antd"
import { PlayCircleOutlined } from "@ant-design/icons"
import { AutoComplete, Input } from "antd"
import type { TitleSettings } from "../types"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import { useStep4Title } from "../hooks/useStep4Title"
@@ -29,6 +30,12 @@ interface Step4TitleSettingsProps {
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
/* ── 批量生成(#1677)── */
/** 生成数量 */
previewCount?: number
/** 每个变体的标题文字(长度=previewCount */
previewTitles?: string[]
onPreviewTitlesChange?: (titles: string[]) => void
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
@@ -44,122 +51,138 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onApplyPreset,
activePreset,
titlePresets,
previewCount = 1,
previewTitles,
onPreviewTitlesChange,
} = props
const isBatch = previewCount > 1
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿保存依赖) */
const updateVariantTitle = (index: number, val: string) => {
if (!previewTitles || !onPreviewTitlesChange) return
const next = [...previewTitles]
next[index] = val
onPreviewTitlesChange(next)
if (index === 0) {
t.updateTitle(val)
}
}
return (
<div className="xx-form-section">
<div className="xx-form-section xx-title-sidebar">
<h3>📝 </h3>
{/* AI 自动选择模式 */}
{t.titleSettings.aiAutoSelect && (
{!isBatch ? (
/* ── 单视频:原有 AI 标题 + 输入框(保持不变) ── */
<>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
{/* 显示当前 AI 选中的标题(只读)+ 换一个按钮 */}
<div className="xx-form-field">
<label> AI </label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
borderRadius: 8,
fontSize: 14,
color: "var(--text-primary, #333)",
}}
>
<span style={{ flex: 1 }}>{t.titleSettings.title || "AI 将自动为你选择标题"}</span>
<button
type="button"
className="xx-btn xx-btn-primary"
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
onClick={t.autoGenerateTitle}
>
🔄
</button>
</div>
</div>
</>
)}
{/* 手动选择模式 */}
{!t.titleSettings.aiAutoSelect && (
<>
<AiTitleGenerator
inputValue={t.aiTitleInput}
onInputChange={t.setAiTitleInput}
generating={t.aiTitleGenerating}
onGenerate={t.handleGenerateAiTitles}
results={t.aiTitleResults}
hasGenerated={t.hasGeneratedTitles}
onSelect={t.handleSelectAiTitle}
selectedTitle={t.titleSettings.title}
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label></label>
<AutoComplete
placeholder="输入或从标题库选择…"
allowClear
maxLength={50}
style={{ width: "100%" }}
value={t.titleSettings.title || undefined}
onChange={(val) => t.updateTitle(val || "")}
options={t.userTitles.map((ut) => ({
label: ut.content,
value: ut.content,
}))}
filterOption={(inputValue, option) => {
const title = (option?.label || option?.value || "") as string
return title.toLowerCase().includes((inputValue || "").toLowerCase())
}}
notFoundContent={
t.userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
{t.titleSettings.aiAutoSelect ? (
<>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label> AI </label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
borderRadius: 8,
fontSize: 14,
color: "var(--text-primary, #333)",
}}
>
<span style={{ flex: 1 }}>
{(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"}
</span>
) : null
}
/>
</div>
<button
type="button"
className="xx-btn xx-btn-primary"
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
onClick={t.autoGenerateTitle}
>
🔄
</button>
</div>
</div>
</>
) : (
<>
<AiTitleGenerator
inputValue={t.aiTitleInput}
onInputChange={t.setAiTitleInput}
generating={t.aiTitleGenerating}
onGenerate={t.handleGenerateAiTitles}
results={t.aiTitleResults}
hasGenerated={t.hasGeneratedTitles}
onSelect={t.handleSelectAiTitle}
selectedTitle={t.titleSettings.title}
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label></label>
<AutoComplete
placeholder="输入标题文字…"
allowClear
maxLength={50}
style={{ width: "100%" }}
value={(previewTitles?.[0] ?? t.titleSettings.title) || undefined}
onChange={(val) => {
t.updateTitle(val || "")
onPreviewTitlesChange?.([val || ""])
}}
options={t.userTitles.map((ut) => ({ label: ut.content, value: ut.content }))}
filterOption={(inputValue, option) => {
const title = (option?.label || option?.value || "") as string
return title.toLowerCase().includes((inputValue || "").toLowerCase())
}}
/>
</div>
</>
)}
</>
) : (
/* ── 批量:N 个独立标题输入框(CSS 浮层实时叠加到对应预览) ── */
<div className="xx-batch-titles">
<div
style={{
fontSize: 12,
color: "var(--text-secondary, #666)",
marginBottom: 10,
lineHeight: 1.6,
}}
>
//
</div>
{Array.from({ length: previewCount }, (_, i) => (
<div className="xx-form-field" key={i}>
<label> {i + 1} </label>
<Input
placeholder={`视频 ${i + 1} 的标题…`}
maxLength={50}
showCount
value={previewTitles?.[i] || ""}
onChange={(e) => updateVariantTitle(i, e.target.value)}
/>
</div>
))}
</div>
)}
{/* 标题样式面板(原 Step5 */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 14px",
background: "rgba(59, 130, 246, 0.08)",
borderRadius: 8,
marginTop: 16,
marginBottom: 12,
border: "1px solid rgba(59, 130, 246, 0.15)",
}}
>
<PlayCircleOutlined style={{ fontSize: 16, color: "#3b82f6" }} />
<span style={{ fontSize: 12, color: "var(--text-secondary, #666)" }}>
</span>
</div>
{/* 标题样式面板(全局共用 */}
<TitleStylePanel
settings={t.titleSettings}
onUpdatePosition={onUpdatePosition}
@@ -12,6 +12,12 @@ import type { AssetItem } from "@/api/assets"
interface Step5VoiceSelectProps {
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
/** 卡片标题(独立配音模式下显示"视频 N 的配音"),默认"选择配音" */
heading?: string
/** 描述文案 */
description?: string
/** 是否使用紧凑卡片样式(独立配音模式下 N 个并排) */
compact?: boolean
}
/** 获取素材实际时长(优先顶层 durationfallback 到 metadata.duration */
@@ -43,6 +49,9 @@ const formatFileSize = (bytes?: number): string => {
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
selectedVoice,
onSelectedVoiceChange,
heading = "🎙️ 选择配音",
description = "从配音库中选择已上传的素材,点击卡片可预览播放",
compact = false,
}) => {
const navigate = useNavigate()
const [playingId, setPlayingId] = useState<string | null>(null)
@@ -148,14 +157,14 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
return (
<div className="xx-form-section">
<h3>🎙 </h3>
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>
</p>
<h3>{heading}</h3>
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>{description}</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
gridTemplateColumns: compact
? "repeat(auto-fill, minmax(160px, 1fr))"
: "repeat(auto-fill, minmax(220px, 1fr))",
gap: 12,
}}
>
@@ -1,9 +1,16 @@
import React from "react"
/**
* Step 5 选择封面(Issue #1677 批量生成改造)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
*/
import React, { useRef } from "react"
import { Modal, Spin } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { CoverConfig } from "../types/cover"
import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleSettings } from "../types"
import { useStep6Cover } from "../hooks/useStep6Cover"
import { useBatchCovers } from "../hooks/useBatchCovers"
import Button from "@/components/ui/Button"
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
import CoverEditorModal from "./cover-settings/CoverEditorModal"
@@ -17,6 +24,15 @@ interface Step6CoverSettingsProps {
titleSettings?: TitleSettings
/** 确认生成步骤产出的最终视频列表 */
generatedVideos: GeneratedVideo[]
/* ── 批量生成(#1677)── */
previewCount?: number
/** 每个变体的标题文字 */
previewTitles?: string[]
/** 每个变体的封面URL(按变体索引) */
previewCovers?: string[]
onPreviewCoversChange?: (urls: string[]) => void
/** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */
selectedVariantIndexes?: number[]
}
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
@@ -46,13 +62,173 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
generatedVideos: props.generatedVideos,
})
const handleAutoGenerate = () => {
generateAutoCover()
}
const previewCount = props.previewCount || 1
const isBatch = previewCount > 1
const previewTitles = props.previewTitles || []
const previewCovers = props.previewCovers || []
/** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */
const cardIndexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: previewCount }, (_, i) => i)
const uploadInputRef = useRef<HTMLInputElement>(null)
const uploadTargetRef = useRef<number>(0)
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
const batchCovers = useBatchCovers({
selectedTemplate: props.selectedTemplate || "",
generatedVideos: props.generatedVideos,
titles: batchTitles,
titleStyle: {
font: props.titleSettings?.font || "思源黑体",
size: props.titleSettings?.size || 28,
color: props.titleSettings?.color || "#ffffff",
position: props.titleSettings?.position || "top",
bold: props.titleSettings?.bold ?? true,
stroke: props.titleSettings?.stroke ?? true,
shadow: props.titleSettings?.shadow ?? false,
},
covers: batchCoversList,
onCoversChange: (urls) => {
// 按卡片顺序写回对应变体索引
const next = [...(props.previewCovers || [])]
cardIndexes.forEach((vi, cardPos) => {
next[vi] = urls[cardPos] || ""
})
props.onPreviewCoversChange?.(next)
},
})
// 预览图:优先 thumbnail_url,其次 upload_url
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
const handleUploadClick = (variantIndex: number) => {
uploadTargetRef.current = variantIndex
uploadInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) {
const variantIndex = uploadTargetRef.current
const cardPos = cardIndexes.indexOf(variantIndex)
if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file)
}
}
/* ── 批量封面 ── */
if (isBatch) {
return (
<div className="xx-form-section">
<h3>🖼 </h3>
<div
style={{
padding: "10px 14px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(16, 185, 129, 0.15)",
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 {completedVideos.length}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<Button
buttonType="primary"
onClick={() => void batchCovers.generateAll()}
disabled={completedVideos.length === 0 || batchCovers.loadingIndex !== null}
>
</Button>
</div>
<div className="xx-cover-grid">
{cardIndexes.map((variantIndex, cardPos) => {
const url = batchCoversList[cardPos]
const isLoading = batchCovers.loadingIndex === cardPos
const isUploading = batchCovers.uploadingIndex === cardPos
const title = batchTitles[cardPos]
return (
<div className="xx-cover-card" key={variantIndex}>
<div className="xx-cover-card-title"> {variantIndex + 1}</div>
<div className="xx-cover-card-box">
{isLoading || isUploading ? (
<div className="xx-cover-card-loading">
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<span>{isLoading ? "AI 选帧中…" : "上传中…"}</span>
</div>
) : url ? (
<img
src={url}
alt={`视频${variantIndex + 1}封面`}
className="xx-cover-card-img"
/>
) : (
<div className="xx-cover-card-placeholder">
<span style={{ fontSize: 26 }}>🖼</span>
<span style={{ fontSize: 12 }}></span>
</div>
)}
<div className="xx-cover-card-ratio">9:16</div>
</div>
{title && (
<div
style={{
fontSize: 12,
color: "var(--text-secondary, #666)",
marginTop: 6,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={title}
>
{title}
</div>
)}
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
onClick={() => void batchCovers.generateOne(cardPos)}
disabled={isLoading || isUploading}
>
{url ? "🔄 重新生成" : "✨ 自动生成"}
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
onClick={() => handleUploadClick(variantIndex)}
disabled={isLoading || isUploading}
>
📤
</button>
</div>
</div>
)
})}
</div>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
)
}
/* ── 单视频:原有流程保持不变 ── */
return (
<div className="xx-form-section">
<h3>🖼 </h3>
@@ -75,7 +251,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
)}
<div className="xx-cover-actions">
<Button buttonType="primary" onClick={handleAutoGenerate} disabled={!finalVideo}>
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}>
</Button>
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
@@ -1,79 +0,0 @@
/**
* Step 7 确认生成组件
*/
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../types/cover"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { useStep7Generate } from "../hooks/useStep7Generate"
import SummaryCard from "./step7-confirm/SummaryCard"
import GenerationStatus from "./step7-confirm/GenerationStatus"
interface Step7ConfirmGenerateProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
title: string
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
presetVoices: PresetVoiceItem[]
clonedVoices: VoiceClone[]
coverSettings: CoverConfig
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
onRetry: () => void
onDismissError: () => void
}
const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
const {
templateName,
materialSummary,
title,
voiceName,
coverSummary,
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
handleScrollToPreview,
} = useStep7Generate(props)
const { onRetry, onDismissError } = props
return (
<div className="xx-form-section">
<h3> </h3>
<SummaryCard
templateName={templateName}
materialSummary={materialSummary}
title={title}
voiceName={voiceName}
coverSummary={coverSummary}
/>
<GenerationStatus
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
getGenerationPhase={getGenerationPhase}
onScrollToPreview={handleScrollToPreview}
onRetry={onRetry}
onDismissError={onDismissError}
/>
</div>
)
}
export default Step7ConfirmGenerate
@@ -1,44 +0,0 @@
import React from "react"
interface SummaryCardProps {
templateName: string
materialSummary: string
title: string
voiceName: string
coverSummary: string
}
const SummaryCard: React.FC<SummaryCardProps> = ({
templateName,
materialSummary,
title,
voiceName,
coverSummary,
}) => {
return (
<div className="xx-summary-card">
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{templateName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{materialSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{voiceName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{coverSummary}</span>
</div>
</div>
)
}
export default SummaryCard
+5 -2
View File
@@ -33,10 +33,13 @@ export const STEPS = [
{ key: 2, label: "选择素材" },
{ key: 3, label: "选择配音" },
{ key: 4, label: "选择标题" },
{ key: 5, label: "确认生成" },
{ key: 6, label: "选择封面" },
{ key: 5, label: "选择封面" },
]
/* ── 批量生成限制 ── */
export const MAX_PREVIEW_COUNT = 10
export const MIN_PREVIEW_COUNT = 1
/* ── 标题位置选项 ── */
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
+378
View File
@@ -2900,3 +2900,381 @@
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
}
/* ================================================================
Issue #1677 多视频批量生成
================================================================ */
/* ── Step4 布局对调:左侧预览大区域,右侧标题边栏 ── */
.xx-generate-layout.step4-layout {
grid-template-columns: 1fr 380px;
align-items: start;
}
.xx-generate-preview-col {
min-width: 0;
position: sticky;
top: 16px;
}
.xx-generate-preview-col .xx-form-section {
margin: 0;
}
.xx-title-sidebar {
max-height: calc(100vh - 140px);
overflow-y: auto;
}
/* ── 数量选择弹窗 ── */
.xx-modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.xx-modal-box {
background: var(--bg-primary, #fff);
border-radius: 16px;
padding: 28px;
width: 420px;
max-width: calc(100vw - 32px);
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18);
}
.xx-count-selector {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin: 8px 0 16px;
}
.xx-count-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border-primary, #d9d9d9);
background: var(--bg-secondary, #f5f5f5);
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--text-primary, #333);
transition: all 0.15s;
}
.xx-count-btn:hover:not(:disabled) {
border-color: #1677ff;
color: #1677ff;
}
.xx-count-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.xx-count-input {
width: 88px;
height: 52px;
text-align: center;
font-size: 26px;
font-weight: 700;
border: 2px solid var(--border-primary, #d9d9d9);
border-radius: 12px;
color: var(--text-primary, #333);
background: var(--bg-primary, #fff);
}
.xx-count-input:focus {
outline: none;
border-color: #1677ff;
}
/* 隐藏 number input 上下箭头 */
.xx-count-input::-webkit-outer-spin-button,
.xx-count-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.xx-count-input {
-moz-appearance: textfield;
appearance: textfield;
}
.xx-count-quick {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 20px;
}
.xx-count-chip {
padding: 6px 16px;
border-radius: 999px;
border: 1px solid var(--border-primary, #d9d9d9);
background: var(--bg-primary, #fff);
font-size: 13px;
cursor: pointer;
color: var(--text-secondary, #666);
transition: all 0.15s;
}
.xx-count-chip:hover {
border-color: #1677ff;
color: #1677ff;
}
.xx-count-chip.active {
background: #1677ff;
border-color: #1677ff;
color: #fff;
}
.xx-count-actions {
display: flex;
gap: 12px;
}
.xx-count-actions .xx-btn {
flex: 1;
}
/* ── 批量预览网格 ── */
.xx-variant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.xx-variant-card {
position: relative;
border: 2px solid var(--border-primary, #e8e8e8);
border-radius: 12px;
padding: 10px;
background: var(--bg-primary, #fff);
cursor: pointer;
transition: all 0.18s;
}
.xx-variant-card:hover {
border-color: #91caff;
}
.xx-variant-card.selected {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
}
.xx-variant-card.failed {
border-color: #ffccc7;
cursor: default;
}
.xx-variant-check {
position: absolute;
top: 14px;
left: 14px;
z-index: 3;
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid #fff;
background: rgba(0, 0, 0, 0.35);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.xx-variant-check.checked {
background: #1677ff;
border-color: #1677ff;
}
.xx-variant-index {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #666);
margin-bottom: 8px;
}
.xx-variant-video-wrap {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #000;
aspect-ratio: 9 / 16;
max-height: 420px;
display: flex;
align-items: center;
justify-content: center;
}
.xx-variant-loading,
.xx-variant-failed {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--text-secondary, #999);
font-size: 12px;
padding: 16px;
text-align: center;
}
.xx-variant-progress {
width: 120px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.25);
overflow: hidden;
}
.xx-variant-progress-bar {
height: 100%;
background: #1677ff;
border-radius: 2px;
transition: width 0.4s;
}
.xx-variant-progress-text {
color: rgba(255, 255, 255, 0.85);
font-size: 12px;
}
.xx-variant-title-overlay {
position: absolute;
left: 8%;
right: 8%;
text-align: center;
font-weight: 700;
line-height: 1.3;
pointer-events: none;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
word-break: break-all;
}
.xx-variant-title-overlay.pos-top {
top: 8%;
}
.xx-variant-title-overlay.pos-center {
top: 50%;
transform: translateY(-50%);
}
.xx-variant-title-overlay.pos-bottom,
.xx-variant-title-overlay.pos-custom {
bottom: 10%;
}
.xx-variant-footer {
min-height: 22px;
margin-top: 8px;
font-size: 12px;
}
.xx-variant-ready-tag {
color: #52c41a;
display: inline-flex;
align-items: center;
gap: 4px;
}
.xx-variant-skip-tag {
color: var(--text-tertiary, #999);
}
/* ── 批量配音列表 ── */
.xx-per-voice-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.xx-per-voice-list .xx-form-section {
margin: 0;
}
/* ── 批量封面网格 ── */
.xx-cover-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.xx-cover-card {
border: 1px solid var(--border-primary, #e8e8e8);
border-radius: 12px;
padding: 10px;
background: var(--bg-primary, #fff);
}
.xx-cover-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #666);
margin-bottom: 8px;
}
.xx-cover-card-box {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #000;
aspect-ratio: 9 / 16;
max-height: 300px;
}
.xx-cover-card-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.xx-cover-card-placeholder,
.xx-cover-card-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-tertiary, #999);
background: var(--bg-secondary, #f7f7f7);
font-size: 13px;
}
.xx-cover-card-ratio {
position: absolute;
right: 6px;
bottom: 6px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
}
/* ── 响应式:窄屏 Step4 回退单列 ── */
@media (max-width: 960px) {
.xx-generate-layout.step4-layout {
grid-template-columns: 1fr;
}
.xx-generate-preview-col {
position: static;
}
.xx-title-sidebar {
max-height: none;
}
}
@@ -29,6 +29,19 @@ export interface UseGenerateVideoProps {
}
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */
onGenerationSuccess?: () => void
/* ── 批量生成(#1677)── */
/** 生成数量(1=单条旧逻辑,>1=批量) */
previewCount?: number
/** 每个变体的标题文字(长度=count 时各自独立) */
variantTitles?: string[]
/** 独立配音模式下每个变体的配音ID(空数组=共用 selectedVoice */
variantVoiceLibraryIds?: string[]
/** 是否独立配音 */
voiceModePerVideo?: boolean
/** 每个变体的封面URL(空数组=回退 coverSettings */
variantCoverUrls?: string[]
/** 勾选要生成的变体索引(批量模式) */
selectedVariantIndexes?: number[]
}
/** 生成阶段 */
@@ -44,7 +57,7 @@ export interface UseGenerateVideoResult {
generated: boolean
generateError: string | null
generatedVideos: GeneratedVideo[]
generate: () => Promise<void>
generate: () => Promise<boolean>
retry: () => void
dismissError: () => void
download: () => Promise<void>
@@ -17,31 +17,25 @@ const MAX_RETRYABLE_ERRORS = 10
const MAX_RESULTS_RETRIES = 3
/**
* 生成状态轮询 Hookv2改用 /generation/tasks/{task_id}
* 生成状态轮询 Hookv3支持批量多任务
*
* 旧版轮询 GET /templates/{id}/editor/generation-status 依赖 plan 维度状态,
* 在编辑流程数据链路断裂时拿不到 task_id。新版直接使用 POST /generation/tasks
* 返回的 task_id 轮询任务详情,不再依赖 plan
*
* 错误处理:
* - 4xx(尤其 404)视为不可恢复,立即 onFailed,不再重试
* - 5xx / 网络错误重试,最多连续 MAX_RETRYABLE_ERRORS 次
* - 任务完成后获取结果失败会重试 MAX_RESULTS_RETRIES 次,仍失败则 onFailed
* startPolling(taskId) 轮询单个任务;
* startPollingBatch(taskIds) 并行轮询 N 个任务,全部完成后聚合结果,
* 任一任务失败即整体失败(其余任务仍在后端继续,不影响)
* 进度为所有任务平均值。
*/
export const useGenerationPolling = ({
export function useGenerationPolling({
onProgress,
onComplete,
onFailed,
}: UseGenerationPollingOptions) => {
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
}: UseGenerationPollingOptions) {
const progressTimer = useRef<ReturnType<typeof setTimeout>[]>([])
const cancelledRef = useRef(false)
const clearTimer = useCallback(() => {
cancelledRef.current = true
if (progressTimer.current) {
clearTimeout(progressTimer.current)
progressTimer.current = undefined
}
progressTimer.current.forEach((t) => clearTimeout(t))
progressTimer.current = []
}, [])
/** 任务完成后拉取结果列表,带重试 */
@@ -62,85 +56,151 @@ export const useGenerationPolling = ({
[],
)
const extractErrorMessage = (pollErr: unknown, status: number): string => {
const msg =
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)?.detail) ||
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)?.message) ||
`查询任务失败 (${status})`
return safeExtractError(msg)
}
/** 轮询单个任务,resolve 该任务的结果视频数组;失败时 reject(new Error(msg)) */
const pollSingleTask = useCallback(
(taskId: string, runId: number, onTaskProgress?: (pct: number) => void): Promise<unknown[]> => {
return new Promise((resolve, reject) => {
let consecutiveErrors = 0
let done = false
const poll = async () => {
if (cancelledRef.current || done) return
try {
const task = await getGenerationTask(taskId)
if (cancelledRef.current || done) return
consecutiveErrors = 0
if (task.status === "completed") {
done = true
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
if (videos === null) {
reject(new Error("视频已生成,但获取结果列表失败,请稍后在任务列表查看"))
return
}
resolve(videos)
return
}
if (task.status === "failed" || task.status === "cancelled") {
done = true
const rawMsg =
task.error_info?.error_message ||
task.error_message ||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
reject(new Error(safeExtractError(rawMsg)))
return
}
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
if (onTaskProgress) {
onTaskProgress(pct)
} else if (runId === 0) {
onProgress(pct)
}
const timer = setTimeout(poll, 2000)
progressTimer.current.push(timer)
} catch (pollErr) {
if (cancelledRef.current || done) return
console.error("[轮询出错] taskId:", taskId, pollErr)
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
if (status && status >= 400 && status < 500) {
done = true
reject(new Error(extractErrorMessage(pollErr, status)))
return
}
consecutiveErrors += 1
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
done = true
reject(new Error("任务状态查询连续失败,请稍后在任务列表查看结果"))
return
}
const timer = setTimeout(poll, 3000)
progressTimer.current.push(timer)
}
}
const timer = setTimeout(poll, 1500)
progressTimer.current.push(timer)
})
},
[onProgress, fetchResultsWithRetry],
)
/** 单任务轮询(兼容旧调用) */
const startPolling = useCallback(
(taskId: string) => {
cancelledRef.current = false
let consecutiveErrors = 0
const poll = async () => {
if (cancelledRef.current) return
try {
const task = await getGenerationTask(taskId)
consecutiveErrors = 0
if (task.status === "completed") {
onProgress(100)
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
if (videos === null) {
const errorMsg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看"
console.error("[生成结果获取失败] taskId:", taskId)
onFailed(errorMsg)
message.error(errorMsg)
return
}
onComplete(videos)
message.success("视频生成完成!")
return
}
if (task.status === "failed" || task.status === "cancelled") {
const rawMsg =
task.error_info?.error_message ||
task.error_message ||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
const errorMsg = safeExtractError(rawMsg)
console.error("[生成失败] taskId:", taskId, "响应:", task)
onFailed(errorMsg)
message.error(errorMsg)
return
}
// pending / waiting / running — 继续轮询
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
onProgress(pct)
progressTimer.current = setTimeout(poll, 2000)
} catch (pollErr) {
const runId = 0
pollSingleTask(taskId, runId)
.then((videos) => {
if (cancelledRef.current) return
console.error("[轮询出错] taskId:", taskId, pollErr)
// 4xx 不可恢复,立即失败
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
if (status && status >= 400 && status < 500) {
const msg =
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
?.detail) ||
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
?.message) ||
`查询任务失败 (${status})`
const errorMsg = safeExtractError(msg)
onFailed(errorMsg)
message.error(errorMsg)
return
}
consecutiveErrors += 1
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
const errorMsg = "任务状态查询连续失败,请稍后在任务列表查看结果"
onFailed(errorMsg)
message.error(errorMsg)
return
}
progressTimer.current = setTimeout(poll, 3000)
}
}
progressTimer.current = setTimeout(poll, 1500)
onProgress(100)
onComplete(videos)
message.success("视频生成完成!")
})
.catch((err: Error) => {
if (cancelledRef.current) return
console.error("[生成失败] taskId:", taskId, err.message)
onFailed(err.message)
message.error(err.message)
})
},
[onProgress, onComplete, onFailed, fetchResultsWithRetry],
[pollSingleTask, onProgress, onComplete, onFailed],
)
return { startPolling, clearTimer }
/** 批量多任务轮询:全部完成后聚合结果;任一失败即整体失败 */
const startPollingBatch = useCallback(
(taskIds: string[]) => {
cancelledRef.current = false
const runId = Date.now()
const progressMap = new Map<string, number>()
const reportAggregateProgress = () => {
if (cancelledRef.current) return
const values = taskIds.map((id) => progressMap.get(id) ?? 0)
const avg = Math.round(values.reduce((a, b) => a + b, 0) / Math.max(values.length, 1))
onProgress(Math.min(avg, 99))
}
const tasks = taskIds.map((taskId) =>
pollSingleTask(taskId, runId, (pct) => {
progressMap.set(taskId, pct)
reportAggregateProgress()
}).then((videos) => {
progressMap.set(taskId, 100)
reportAggregateProgress()
return videos
}),
)
Promise.all(tasks)
.then((results) => {
if (cancelledRef.current) return
onProgress(100)
const allVideos = results.flat()
onComplete(allVideos)
message.success(`全部 ${taskIds.length} 个视频生成完成!`)
})
.catch((err: Error) => {
if (cancelledRef.current) return
console.error("[批量生成失败]", err.message)
onFailed(err.message)
message.error(err.message)
})
},
[pollSingleTask, onProgress, onComplete, onFailed],
)
return { startPolling, startPollingBatch, clearTimer }
}
@@ -0,0 +1,150 @@
/**
* 批量封面 HookIssue #1677
* N 个视频时:逐个自动生成封面(从对应成片抽帧 + 叠加对应标题)或上传自定义封面
*/
import { useCallback, useState } from "react"
import { message } from "antd"
import { generateCover } from "@/api/generation"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
import type { GeneratedVideo } from "@/api/template-editor"
interface UseBatchCoversOptions {
selectedTemplate: string
generatedVideos: GeneratedVideo[]
/** 每个变体的标题文字 */
titles: string[]
/** 标题样式(全局共用) */
titleStyle: {
font: string
size: number
color: string
position: string
bold: boolean
stroke: boolean
shadow: boolean
}
covers: string[]
onCoversChange: (urls: string[]) => void
}
export function useBatchCovers({
selectedTemplate,
generatedVideos,
titles,
titleStyle,
covers,
onCoversChange,
}: UseBatchCoversOptions) {
const [loadingIndex, setLoadingIndex] = useState<number | null>(null)
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null)
const patchCover = useCallback(
(index: number, url: string) => {
const next = [...covers]
next[index] = url
onCoversChange(next)
},
[covers, onCoversChange],
)
/** 为第 index 个视频自动生成封面 */
const generateOne = useCallback(
async (index: number) => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
return
}
setLoadingIndex(index)
try {
const titleText = titles[index] || ""
const response = await generateCover(selectedTemplate, {
generated_video_id: target.id,
video_url: target.file_url || target.download_url || "",
cover_type: "ai_frame",
...(titleText
? {
title_config: {
text: titleText,
font: titleStyle.font,
font_size: titleStyle.size,
font_color: titleStyle.color,
position: titleStyle.position,
bold: titleStyle.bold,
stroke: titleStyle.stroke,
shadow: titleStyle.shadow,
},
}
: {}),
})
const url = response.cover?.image_url || response.cover?.thumbnail_url || ""
if (url) {
patchCover(index, url)
message.success(`视频 ${index + 1} 封面生成成功`)
} else {
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
}
} catch (err) {
console.error(`[封面] 视频 ${index + 1} 生成失败:`, err)
message.error(`视频 ${index + 1} 封面生成失败,请重试`)
} finally {
setLoadingIndex(null)
}
},
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover],
)
/** 为第 index 个视频上传自定义封面 */
const uploadOne = useCallback(
async (index: number, file: File) => {
setUploadingIndex(index)
try {
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
message.error("未找到素材库,请先创建")
return
}
const result = await uploadAssetDirect({
file,
library_id: imageLib.id,
})
const url = result?.url || ""
if (url) {
patchCover(index, url)
message.success(`视频 ${index + 1} 封面已上传`)
} else {
message.warning("上传完成但未获取到图片URL,请重试")
}
} catch (err) {
console.error(`[封面] 视频 ${index + 1} 上传失败:`, err)
message.error("封面上传失败,请重试")
} finally {
setUploadingIndex(null)
}
},
[patchCover],
)
/** 一键全部自动生成(串行,避免队列限流) */
const generateAll = useCallback(async () => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
for (let i = 0; i < finalVideos.length; i++) {
if (covers[i]) continue // 已有封面跳过
// eslint-disable-next-line no-await-in-loop
await generateOne(i)
}
message.success("全部封面已生成")
}, [generatedVideos, covers, generateOne])
return {
loadingIndex,
uploadingIndex,
generateOne,
uploadOne,
generateAll,
}
}
export default useBatchCovers
@@ -0,0 +1,285 @@
/**
* 批量服务器预览 Hook(Issue #1677 多视频批量生成)
*
* 核心职责:
* 1. 调用 POST /generation/previewpreview_count=N)一次创建 N 个独立变体任务
* 2. 对每个变体 task_id 分别轮询 GET /generation/preview/{task_id}
* 3. 返回每个变体的状态/进度/视频URL,供网格播放器展示
*
* N=1 时不启用(走前端 Canvas 实时预览,零回归);
* N>1 时进入标题页自动触发;素材/配音等配置变化后重新触发。
*/
import { useState, useCallback, useRef, useEffect } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
import type { CreatePreviewRequest } from "@/api/generation/types"
export type VariantPreviewStatus = "loading" | "ready" | "failed"
export interface VariantPreview {
/** 变体序号(0-based */
index: number
taskId: string
status: VariantPreviewStatus
progress: number
videoUrl: string | null
error: string | null
}
interface UseBatchPreviewOptions {
/** 是否启用(仅 previewCount>1 且在标题页时启用) */
enabled: boolean
/** 构建预览请求参数(每次触发时调用,获取最新配置) */
buildRequest: () => CreatePreviewRequest
/** 批量预览任务创建成功回调(回传变体 taskId 列表与 source_edit_plan_id */
onPreviewTasksCreated?: (taskIds: string[], sourceEditPlanId?: string) => void
}
interface UseBatchPreviewReturn {
variants: VariantPreview[]
/** 整体状态:loading=任一进行中,ready=全部完成,failed=有失败 */
status: "idle" | "loading" | "ready" | "partial_failed" | "failed"
/** 总进度 0-100(各变体平均值) */
progress: number
/** 失败的变体数量 */
failedCount: number
/** 手动重新触发 */
trigger: () => void
}
const POLL_INTERVAL = 2000
const POLL_TIMEOUT = 180_000
const MAX_NETWORK_RETRIES = 2
/**
* 对配置参数做指纹,用于检测配置是否变化(标题文字/样式变化不触发重渲染,仅CSS浮层叠加)
*/
function buildFingerprint(req: CreatePreviewRequest): string {
// 不含 titles/title_config:标题文字与样式由 CSS 浮层实时叠加,变化不触发重渲染
return JSON.stringify({
t: req.template_id,
a: [...(req.asset_ids || [])].sort(),
r: req.video_ratio,
v: req.voice_library_id,
vs: req.voice_library_ids,
pc: req.preview_count,
b: req.bgm_config,
})
}
export function useBatchPreview({
enabled,
buildRequest,
onPreviewTasksCreated,
}: UseBatchPreviewOptions): UseBatchPreviewReturn {
const [variants, setVariants] = useState<VariantPreview[]>([])
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "partial_failed" | "failed">(
"idle",
)
const requestSeqRef = useRef(0)
const pollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true)
const buildRequestRef = useRef(buildRequest)
buildRequestRef.current = buildRequest
const onCreatedRef = useRef(onPreviewTasksCreated)
onCreatedRef.current = onPreviewTasksCreated
const clearTimers = useCallback(() => {
pollTimersRef.current.forEach((t) => clearTimeout(t))
pollTimersRef.current = []
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
clearTimers()
}
}, [clearTimers])
/** 更新单个变体状态 */
const patchVariant = useCallback((taskId: string, patch: Partial<VariantPreview>) => {
setVariants((prev) => prev.map((v) => (v.taskId === taskId ? { ...v, ...patch } : v)))
}, [])
/** 轮询单个变体任务 */
const pollVariant = useCallback(
async (taskId: string, seq: number, retries = 0) => {
if (seq !== requestSeqRef.current || !mountedRef.current) return
try {
const st = await getPreviewStatus(taskId)
if (seq !== requestSeqRef.current || !mountedRef.current) return
if (st.status === "completed" && st.video_url) {
patchVariant(taskId, {
status: "ready",
videoUrl: st.video_url,
progress: 100,
error: null,
})
return
}
if (st.status === "failed" || st.status === "cancelled") {
patchVariant(taskId, {
status: "failed",
error:
st.status === "cancelled" ? "预览任务已取消" : st.error_message || "预览渲染失败",
})
return
}
if (typeof st.progress === "number") {
patchVariant(taskId, { progress: Math.round(st.progress) })
}
const timer = setTimeout(() => pollVariant(taskId, seq), POLL_INTERVAL)
pollTimersRef.current.push(timer)
} catch (err) {
if (seq !== requestSeqRef.current || !mountedRef.current) return
if (retries < MAX_NETWORK_RETRIES) {
console.warn(`[BatchPreview] 变体 ${taskId} 轮询网络错误,第 ${retries + 1} 次重试`, err)
const timer = setTimeout(() => pollVariant(taskId, seq, retries + 1), POLL_INTERVAL * 2)
pollTimersRef.current.push(timer)
} else {
patchVariant(taskId, { status: "failed", error: "网络错误,无法获取预览状态" })
}
}
},
[patchVariant],
)
/** 创建批量预览任务并开始轮询 */
const trigger = useCallback(() => {
if (!enabled) return
const request = buildRequestRef.current()
if (!request.template_id || !request.asset_ids?.length) return
const count = request.preview_count && request.preview_count > 1 ? request.preview_count : 0
if (!count) return
clearTimers()
const seq = ++requestSeqRef.current
setStatus("loading")
setVariants(
Array.from({ length: count }, (_, i) => ({
index: i,
taskId: "",
status: "loading" as const,
progress: 0,
videoUrl: null,
error: null,
})),
)
createPreview(request)
.then((resp) => {
if (seq !== requestSeqRef.current || !mountedRef.current) return
const items = resp.items || []
const taskIds = items.map((it) => it.task_id).filter(Boolean)
if (taskIds.length === 0) {
setStatus("failed")
setVariants((prev) =>
prev.map((v) => ({ ...v, status: "failed", error: "未创建预览任务" })),
)
return
}
onCreatedRef.current?.(taskIds, resp.source_edit_plan_id)
// 用返回的 task_id 填充变体(按 variant_index 对齐)
setVariants((prev) =>
prev.map((v) => {
const item = items.find((it) => it.variant_index === v.index) || items[v.index]
return item ? { ...v, taskId: item.task_id } : v
}),
)
// 超时保护
timeoutTimerRef.current = setTimeout(() => {
if (seq !== requestSeqRef.current || !mountedRef.current) return
setVariants((prev) =>
prev.map((v) =>
v.status === "loading"
? { ...v, status: "failed", error: "预览渲染超时,请重试" }
: v,
),
)
}, POLL_TIMEOUT)
// 分别轮询每个变体
items.forEach((item) => {
if (item.task_id) pollVariant(item.task_id, seq)
})
})
.catch((err: unknown) => {
if (seq !== requestSeqRef.current || !mountedRef.current) return
console.error("[BatchPreview] 创建批量预览失败:", err)
const errData = (err as { response?: { data?: { detail?: string; message?: string } } })
?.response?.data
setStatus("failed")
setVariants((prev) =>
prev.map((v) => ({
...v,
status: "failed",
error: errData?.detail || errData?.message || "预览任务创建失败,请重试",
})),
)
})
}, [enabled, clearTimers, pollVariant])
/* ── 自动触发 + 配置变更检测 ── */
const request = enabled ? buildRequest() : null
const currentFingerprint = request
? request.template_id && request.asset_ids?.length && (request.preview_count || 1) > 1
? buildFingerprint(request)
: ""
: ""
const didInitRef = useRef(false)
useEffect(() => {
if (!enabled || !currentFingerprint) {
didInitRef.current = false
requestSeqRef.current += 1
clearTimers()
setStatus("idle")
setVariants([])
return
}
if (!didInitRef.current) {
didInitRef.current = true
trigger()
}
}, [enabled, currentFingerprint, trigger, clearTimers])
// 配置变更(素材/配音/数量)→ 重新渲染;标题文字变化不触发(CSS浮层实时叠加)
const prevFingerprintRef = useRef(currentFingerprint)
useEffect(() => {
if (!enabled || !currentFingerprint) return
const prev = prevFingerprintRef.current
prevFingerprintRef.current = currentFingerprint
if (!prev || prev === currentFingerprint) return
trigger()
}, [enabled, currentFingerprint, trigger])
/* ── 派生状态 ── */
const progress =
variants.length > 0
? Math.round(variants.reduce((sum, v) => sum + v.progress, 0) / variants.length)
: 0
const failedCount = variants.filter((v) => v.status === "failed").length
const readyCount = variants.filter((v) => v.status === "ready").length
useEffect(() => {
if (status !== "loading" || variants.length === 0) return
if (readyCount === variants.length) {
setStatus("ready")
} else if (readyCount + failedCount === variants.length && failedCount > 0) {
setStatus(failedCount === variants.length ? "failed" : "partial_failed")
}
}, [variants, status, readyCount, failedCount])
return { variants, status, progress, failedCount, trigger }
}
export default useBatchPreview
@@ -1,6 +1,6 @@
/**
* GeneratePage 表单状态管理
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
* 集中管理 5 步向导的所有共享状态、API 加载、URL 参数解析
*/
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
@@ -100,6 +100,26 @@ export interface GenerateFormState {
/** 从预览响应中提取的 source_edit_plan_id(供 fallback 路径使用) */
storedSourceEditPlanId: string | null
setStoredSourceEditPlanId: (planId: string | null) => void
/* ── 批量生成(Issue #1677)── */
/** 生成数量(1~10),1=单条旧逻辑 */
previewCount: number
setPreviewCount: (n: number) => void
/** 每个变体的标题文字,长度=previewCount[0] 与 titleSettings.title 保持同步 */
previewTitles: string[]
setPreviewTitles: (titles: string[] | ((prev: string[]) => string[])) => void
/** false=所有视频共用一个配音;true=每个视频独立配音 */
voiceModePerVideo: boolean
setVoiceModePerVideo: (v: boolean) => void
/** 独立配音模式下每个变体的配音素材ID,长度=previewCount */
voiceLibraryIds: string[]
setVoiceLibraryIds: (ids: string[] | ((prev: string[]) => string[])) => void
/** 每个变体的封面URL(自动生成或上传),长度=previewCount,空串=未设置 */
previewCovers: string[]
setPreviewCovers: (urls: string[] | ((prev: string[]) => string[])) => void
/** 确认生成时勾选的变体索引 */
selectedVariantIds: number[]
setSelectedVariantIds: (ids: number[] | ((prev: number[]) => number[])) => void
}
export const useGenerateFormState = (): GenerateFormState => {
@@ -185,6 +205,14 @@ export const useGenerateFormState = (): GenerateFormState => {
null,
)
/* ── 批量生成状态(Issue #1677)── */
const [previewCount, setPreviewCount] = useState(1)
const [previewTitles, setPreviewTitles] = useState<string[]>([""])
const [voiceModePerVideo, setVoiceModePerVideo] = useState(false)
const [voiceLibraryIds, setVoiceLibraryIds] = useState<string[]>([""])
const [previewCovers, setPreviewCovers] = useState<string[]>([""])
const [selectedVariantIds, setSelectedVariantIds] = useState<number[]>([0])
/* ── 从 URL / 编辑计划加载配置 ── */
usePlanConfigLoader({
editPlanId,
@@ -233,5 +261,17 @@ export const useGenerateFormState = (): GenerateFormState => {
setPreviewTaskId,
storedSourceEditPlanId,
setStoredSourceEditPlanId,
previewCount,
setPreviewCount,
previewTitles,
setPreviewTitles,
voiceModePerVideo,
setVoiceModePerVideo,
voiceLibraryIds,
setVoiceLibraryIds,
previewCovers,
setPreviewCovers,
selectedVariantIds,
setSelectedVariantIds,
}
}
@@ -38,7 +38,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerateError(errorMsg)
}, [])
const { startPolling, clearTimer } = useGenerationPolling({
const { startPolling, startPollingBatch, clearTimer } = useGenerationPolling({
onProgress: handleProgress,
onComplete: handleComplete,
onFailed: handleFailed,
@@ -83,7 +83,11 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
}
const hide = message.loading("正在生成预览视频...", 0)
const isBatch = (props.previewCount || 1) > 1
const hide = message.loading(
isBatch ? `正在生成 ${props.previewCount} 个视频...` : "正在生成预览视频...",
0,
)
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
@@ -92,6 +96,29 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || ""
/* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */
const indexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
const batchCount = isBatch ? indexes.length : 1
// 标题文字数组:批量时按勾选顺序
const titlesArr =
isBatch && (props.variantTitles?.length || 0) >= batchCount
? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "")
: []
// 配音数组:独立配音模式按勾选顺序;否则不传(回退共用 voice_library_id
const voiceArr =
isBatch && props.voiceModePerVideo && props.variantVoiceLibraryIds?.length
? indexes.map((i) => props.variantVoiceLibraryIds![i] || voiceLibraryId)
: []
// 封面数组:批量时按勾选顺序(未设置封面的变体传空串,后端回退智能封面)
const coversArr =
isBatch && props.variantCoverUrls?.length
? indexes.map((i) => props.variantCoverUrls![i] || "")
: []
try {
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
@@ -109,6 +136,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
},
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(isBatch ? { count: batchCount } : {}),
...(titlesArr.length ? { titles: titlesArr } : {}),
...(voiceArr.length ? { voice_library_ids: voiceArr } : {}),
...(coversArr.length ? { cover_urls: coversArr } : {}),
...(props.titleSettings?.title
? {
title_config: {
@@ -133,12 +164,16 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
: {}),
})
hide()
const taskId = taskResp.items?.[0]?.id
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean)
if (!taskId) {
if (taskIds.length === 0) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
startPolling(taskId)
if (taskIds.length > 1) {
startPollingBatch(taskIds)
} else {
startPolling(taskIds[0])
}
} catch (err) {
hide()
throw err
@@ -154,7 +189,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
return false
}
return true
}, [props, clearTimer, startPolling, selectedTemplate])
}, [props, clearTimer, startPolling, startPollingBatch, selectedTemplate])
const retry = useCallback(() => {
setGenerateError(null)
@@ -114,8 +114,11 @@ export function useServerPreview({
const resp = await createPreview(request)
if (seq !== requestSeqRef.current || !mountedRef.current) return
setTaskId(resp.task_id)
onCreatedRef.current?.(resp.task_id, resp.source_edit_plan_id)
// 兼容批量响应 {items, total}:取第一个变体
const firstTask = resp.items?.[0]
const taskId = firstTask?.task_id || ""
setTaskId(taskId)
onCreatedRef.current?.(taskId, resp.source_edit_plan_id)
let completed = false
@@ -132,7 +135,7 @@ export function useServerPreview({
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
try {
const st = await getPreviewStatus(resp.task_id)
const st = await getPreviewStatus(taskId)
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
if (st.status === "completed" && st.video_url) {
@@ -1,112 +0,0 @@
/**
* Step 7 确认生成 Hook
* 封装生成确认页的展示逻辑
*/
import { useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../types/cover"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { getAssetsByKind } from "@/api/assets"
import { COVER_MODE_LABELS } from "../constants"
interface UseStep7GenerateProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
title: string
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
presetVoices: PresetVoiceItem[]
clonedVoices: VoiceClone[]
coverSettings: CoverConfig
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
}
export function useStep7Generate({
templates,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
title,
voiceMode: _voiceMode,
selectedVoice,
selectedClonedVoice: _selectedClonedVoice,
presetVoices: _presetVoices,
clonedVoices: _clonedVoices,
coverSettings,
generating,
generated,
generateError,
progress,
generatedVideos,
}: UseStep7GenerateProps) {
const templateName = useMemo(
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
[templates, selectedTemplate],
)
const materialSummary = useMemo(() => {
if (materialMode === "auto") {
return `${smartSelectedIds.length} 个素材(智能匹配)`
}
return `${selectedMaterials.length} 个素材`
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
// 从配音素材库中查找 voiceName
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const voiceName = useMemo(() => {
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
return asset ? asset.name : "未选择"
}, [voiceMaterials, selectedVoice])
const coverSummary = useMemo(() => {
if (!coverSettings.enabled) return "不使用"
return COVER_MODE_LABELS[coverSettings.mode] || "智能封面"
}, [coverSettings])
const getGenerationPhase = (p: number) => {
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
return { label: "即将完成", icon: "✨" }
}
const handleScrollToPreview = () => {
const el =
document.querySelector(".xx-inline-video-player") ||
document.querySelector(".xx-preview-section")
el?.scrollIntoView({ behavior: "smooth", block: "start" })
}
return {
templateName,
materialSummary,
title,
voiceName,
coverSummary,
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
handleScrollToPreview,
}
}
export default useStep7Generate
@@ -1,6 +1,6 @@
/**
* GeneratePage 步骤导航
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
* GeneratePage 步骤导航Issue #1677 改造后 5 步)
* 步骤:模板(1) → 素材(2) → 配音(3) → 标题+预览+确认生成(4) → 封面(5)
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
@@ -13,10 +13,16 @@ export interface UseStepNavigationOptions {
selectedMaterials: string[]
smartSelectedIds: string[]
titleSettings: TitleSettings
/** 预览是否已就绪(素材已加载,可播放 */
/** 预览是否已就绪(单视频=前端预览素材已加载;批量=服务器预览全部完成 */
previewReady: boolean
/** 是否已完成视频生成(步骤5确认生成后才能进入封面) */
/** 是否已完成视频生成(步骤4确认生成后才能进入封面) */
generated: boolean
/** 批量模式下每个变体的标题 */
previewTitles: string[]
/** 批量模式勾选的变体数 */
selectedCount: number
/** Step1 点下一步时弹出数量选择弹窗 */
onOpenCountModal: () => void
}
export interface UseStepNavigationReturn {
@@ -32,14 +38,21 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
materialMode,
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady,
generated,
previewTitles,
selectedCount,
onOpenCountModal,
} = options
const goNext = () => {
if (currentStep === 1 && !selectedTemplate) {
message.warning("请先选择一个模板")
if (currentStep === 1) {
if (!selectedTemplate) {
message.warning("请先选择一个模板")
return
}
// 选完模板弹数量选择弹窗(每次都弹,不记忆)
onOpenCountModal()
return
}
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
@@ -50,23 +63,27 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请先进行智能匹配并选择素材")
return
}
// Step4(标题+预览):标题必填 + 预览必须已加载
// Step4(标题+预览+确认生成):标题必填 + 预览必须已加载
if (currentStep === 4) {
if (!titleSettings.title.trim()) {
message.warning("请选择或输入标题")
const allTitlesFilled = previewTitles.every((t) => t && t.trim())
if (!allTitlesFilled) {
message.warning("请为每个视频输入标题")
return
}
if (selectedCount === 0) {
message.warning("请至少勾选一个视频")
return
}
if (!previewReady) {
message.warning("预览视频正在加载,请稍候")
return
}
if (!generated) {
message.warning("请先点击「确认生成视频」完成渲染")
return
}
}
// Step5(确认生成):必须已完成生成才能进入封面
if (currentStep === 5 && !generated) {
message.warning("请先生成视频")
return
}
if (currentStep < 6) {
if (currentStep < 5) {
setCurrentStep((s) => s + 1)
}
}
@@ -19,6 +19,9 @@ import "@/pages/generate/GeneratePage"
import "@/pages/generate/components/Step2MaterialSelect"
import "@/pages/generate/components/Step4TitleSettings"
import "@/pages/generate/components/Step5VoiceSelect"
import "@/pages/generate/components/Step3VoiceWithMode"
import "@/pages/generate/components/ServerPreviewGrid"
import "@/pages/generate/components/PreviewCountModal"
import "@/pages/generate/components/PreviewVideoPanel"
import "@/pages/generate/components/GenerateResultPanel"
import "@/pages/generate/components/GenerateStepContent"
@@ -45,6 +48,8 @@ describe("GeneratePage module smoke test", () => {
})
})
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/useBatchPreview"
import "@/pages/generate/hooks/useBatchCovers"
import "@/pages/generate/hooks/usePreviewAssets"
import "@/pages/generate/hooks/useSegmentScheduler"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
+454 -249
View File
@@ -31,25 +31,60 @@ SCENE_CHANGE_THRESHOLD = 30 # 灰度差异阈值
MIN_KEYFRAME_INTERVAL_SEC = 1.0 # 最小关键帧间隔(秒)
MAX_KEYFRAMES = 30 # 最大关键帧数
MIN_KEYFRAMES = 5 # 最小关键帧数
FINGERPRINT_SAMPLE_INTERVAL_SEC = 1.0 # 指纹采样间隔(秒):密集均匀采样,保证两视频时序可对齐
FINGERPRINT_MAX_SAMPLES = 30 # 长视频采样数上限(超过后采样间隔自动放宽)
LONG_VIDEO_SEGMENT_SEC = 30 # 长视频每段秒数
LONG_VIDEO_DURATION_THRESHOLD_SEC = 180 # 3 分钟阈值
MIN_FRAMES_PER_SEGMENT = 2 # 长视频每段最少帧数
# ── 滑动窗口匹配常量 ────────────────────────────────────────────
SEGMENT_MATCH_THRESHOLD = 8 # 帧匹配汉明距离阈值
MIN_CONSECUTIVE_MATCHES = 5 # 最少连续匹配帧数
# ── 滑动窗口匹配常量Issue #1702 重新校准) ─────────────────────
# 阈值经 staging 真实数据回归校准(2026-09-05,worker 容器内离线实验):
# - 同源成片对(20s/11s,各自 2-5% 随机边缘裁剪降重,1s 密集采样):
# 全部帧对最小汉明距离 min=8<=12 命中 10/31 帧(B->A 4/11
# - 异源成片对(4 个不同项目真实视频):最小距离 24,<=16 命中 0 帧
# 8(#1658 旧值)会漏掉同源裁剪(自对照实验:同帧两次 2-5% 随机裁剪距离 4~10),
# 12 能检出同源/局部复用且与异源分布(>=24)间隔 12bit,无误报空间。
PHASH_THRESHOLD = 12
SEGMENT_MATCH_THRESHOLD = PHASH_THRESHOLD # 片段匹配阈值与帧匹配统一(#1702:阈值常量统一来源)
MIN_CONSECUTIVE_MATCHES = 5 # 连续匹配默认门槛;短视频自适应 min(5, max(2, 分片数//2))
MAX_GAP = 2 # 允许的最大间隙帧数
NEIGHBOR_WINDOW = 1 # 分片时序对齐:允许 ±1 邻接偏移(1s 密集采样下即 ±1s,缓解切点不一致)
# ── 融合判定常量 ────────────────────────────────────────────────
PHASH_WEIGHT = 0.7 # pHash 权重
HISTOGRAM_WEIGHT = 0.3 # 直方图权重
MATCH_RATIO_THRESHOLD = 0.7 # 至少 70% 帧匹配
MATCH_RATIO_THRESHOLD = 0.7 # 全片重复(is_duplicate至少 70% 帧匹配
PARTIAL_COVERAGE_THRESHOLD = 0.5 # 局部复用覆盖率 >=50% 也判全片重复
DUPLICATE_THRESHOLD = 0.70 # 融合后相似度阈值
# ── 降重裁剪规避常量(Issue #1702) ─────────────────────────────
# 成片强制 2-5% random_edge_crop 降重只服务外部平台;自查重指纹取中心 90%
# 区域,使两次不同裁剪的同源画面 pHash 距离回到同分布。
FINGERPRINT_CENTER_CROP_RATIO = 0.90
# ── 感知哈希 & 颜色直方图工具函数 ────────────────────────────────
def center_crop_frame(image: np.ndarray, ratio: float = FINGERPRINT_CENTER_CROP_RATIO) -> np.ndarray:
"""取画面中心 ratio 比例区域(裁除四边边缘)。
查重指纹用:random_edge_crop 降重(2-5% 四边随机裁剪)会让同源画面 pHash
位翻转 12-16,污染自查重(Issue #1702)。算 pHash/颜色直方图前先居中裁除
边缘 10%,两次不同裁剪的同源画面中心区域基本重合,指纹不再被降重污染。
降重只服务外部平台,不影响内部查重。
"""
if image is None or image.size == 0:
return image
h, w = image.shape[:2]
ch, cw = int(h * ratio), int(w * ratio)
if ch <= 0 or cw <= 0 or (ch >= h and cw >= w):
return image
y0 = (h - ch) // 2
x0 = (w - cw) // 2
return image[y0 : y0 + ch, x0 : x0 + cw]
def compute_phash(image: np.ndarray, hash_size: int = 8) -> str:
"""计算图像的感知哈希(pHash),基于 DCT(离散余弦变换)。
@@ -101,11 +136,17 @@ def hamming_distance(hash1: str, hash2: str) -> int:
def compute_color_histogram(image: np.ndarray, bins: int = 32) -> list[float]:
"""Compute color histogram for an image."""
"""Compute BGR color histogram for an image.
Issue #1702: 每个通道独立做 NORM_L1 归一化(通道内 Σ=1,是概率分布),
三通道拼接存储。Bhattacharyya 系数对拼接向量直接 Σ√(a*b) 会得到
3 通道之和(范围 [0,3],实测 ~14.9 是旧 L2 归一化的错误结果),
消费方 _bhattacharyya_coefficient 按通道数平均归一到 [0,1]。
"""
hist = []
for i in range(3):
h = cv2.calcHist([image], [i], None, [bins], [0, 256])
h = cv2.normalize(h, h).flatten()
h = cv2.normalize(h, h, norm_type=cv2.NORM_L1).flatten()
hist.extend(h)
return hist
@@ -210,6 +251,30 @@ def detect_keyframe_timestamps(
return keyframe_times
def sample_fingerprint_timestamps(
duration: float,
*,
interval_sec: float = FINGERPRINT_SAMPLE_INTERVAL_SEC,
max_samples: int = FINGERPRINT_MAX_SAMPLES,
) -> list[float]:
"""指纹采样时间戳:固定间隔密集均匀采样(Issue #1702)。
动态场景检测抽帧(#1659)在两个同源视频上会各自取到不同时刻,切点/取帧
错位让对齐帧的 pHash 距离都很大(实测同源对最小距离 12 且配对时序错乱)。
改为固定 1s 间隔均匀采样后,复用片段的帧时刻天然对齐,配合 ±1 邻接窗口
即可检出同源/局部复用。长视频(>max_samples*interval)自动放宽间隔到
duration/max_samples,保证分片数有上限。
"""
if duration <= 0:
return []
step = interval_sec
n_uniform = int(duration / step)
if n_uniform > max_samples:
step = duration / max_samples
count = max(1, int(duration / step))
return [step * (i + 0.5) for i in range(count)]
# ── 数据类 ──────────────────────────────────────────────────────
@@ -297,23 +362,32 @@ def find_duplicate_segments(
target_chunks: list,
*,
match_threshold: int = SEGMENT_MATCH_THRESHOLD,
min_consecutive: int = MIN_CONSECUTIVE_MATCHES,
min_consecutive: Optional[int] = None,
max_gap: int = MAX_GAP,
neighbor_window: int = NEIGHBOR_WINDOW,
) -> list[DuplicateSegment]:
"""滑动窗口时序匹配:找出两组分片之间的重复片段。
"""滑动窗口时序匹配:找出两组分片之间的重复片段Issue #1702 重构)
算法:
1. 对每个 query chunk,找到 target 汉明距离最小的 chunk
2. 距离 <= match_threshold 视为匹配
3. 找连续匹配的 run(允许 max_gap 帧间隙)
4. 连续匹配数 >= min_consecutive 的 run 报告为重复片段
1. 构建 query×target 全量汉明距离矩阵;每个 query chunk 保留所有
距离 <= match_threshold 的候选 target 分片(与帧匹配判定同一阈值)。
2. 时序一致贪心对齐:沿 query 时序推进,run 内优先选择与上一匹配帧
目标序号连贯(0 <= delta <= neighbor_window+1,允许 ±1 邻接窗口 /
时序偏移对齐,缓解场景切割导致的切点、取帧错位)的候选;同距时
偏好大索引,避免重复 hash 塌缩到 target 首帧。
3. 连贯匹配中允许 <= max_gap 帧间隙桥接;断裂后另起新 run——天然
支持局部片段复用(复用片段可出现在任意时序位置,各成独立片段)。
4. 连续匹配帧数 >= min_consecutive 的 run 报为重复片段。短视频自适应:
min_consecutive = min(5, max(2, len(query_chunks)//2))n=1 时
不形成片段,由调用方匹配帧回退兜底。
Args:
query_chunks: 查询视频的分片列表(FingerprintChunk 或 dict
target_chunks: 目标视频的分片列表
match_threshold: 汉明距离匹配阈值
min_consecutive: 最少连续匹配帧数
match_threshold: 汉明距离匹配阈值(统一常量 PHASH_THRESHOLD
min_consecutive: 最少连续匹配帧数None 时按短视频自适应
max_gap: 允许的最大间隙帧数
neighbor_window: 时序对齐允许的目标分片序号邻接窗口
Returns:
DuplicateSegment 列表
@@ -321,95 +395,93 @@ def find_duplicate_segments(
if not query_chunks or not target_chunks:
return []
def _get_phash(chunk) -> str:
def _get(chunk, key):
if isinstance(chunk, dict):
return chunk["phash_binary"]
return chunk.phash_binary
return chunk[key]
return getattr(chunk, key)
def _get_start(chunk) -> int:
if isinstance(chunk, dict):
return chunk["start_time_ms"]
return chunk.start_time_ms
n, m = len(query_chunks), len(target_chunks)
q_ph = [_get(c, "phash_binary") for c in query_chunks]
t_ph = [_get(c, "phash_binary") for c in target_chunks]
def _get_end(chunk) -> int:
if isinstance(chunk, dict):
return chunk["end_time_ms"]
return chunk.end_time_ms
# Step 1: 全量距离矩阵。每个 query chunk 保留所有 <= 阈值的候选 target
# 按距离升序;同距时小索引优先(取最早的对齐位置,贪心连贯推进时最保守,
# 不会越过复用片段末端;重复 hash 的连续帧由 Step 2 的连贯性窗口约束)。
candidates: list[list[tuple[int, int]]] = [] # 每 query 帧: [(target_idx, dist), ...]
for i in range(n):
dists = [hamming_distance(q_ph[i], t_ph[j]) for j in range(m)]
cand = [(j, d) for j, d in enumerate(dists) if d <= match_threshold]
cand.sort(key=lambda x: (x[1], x[0]))
candidates.append(cand)
# Step 1: 逐帧匹配
frame_matches: list[tuple[bool, int, int]] = [] # (is_match, min_dist, best_target_idx)
for qc in query_chunks:
qc_phash = _get_phash(qc)
best_dist = 64
best_idx = 0
for j, tc in enumerate(target_chunks):
d = hamming_distance(qc_phash, _get_phash(tc))
if d < best_dist:
best_dist = d
best_idx = j
frame_matches.append((best_dist <= match_threshold, best_dist, best_idx))
# 短视频自适应连续匹配门槛(Issue #1702 工单公式):
# MIN_CONSECUTIVE_MATCHES = min(5, max(2, 分片数//2))。
# n=1 时门槛为 2 不形成片段,由 _evaluate_candidate 的匹配帧回退
# temporal_coverage 按匹配帧占比估计)兜底检出,不回归。
if min_consecutive is None:
min_consecutive = min(MIN_CONSECUTIVE_MATCHES, max(2, n // 2))
# Step 2: 找连续匹配的 runs
runs: list[tuple[int, int]] = [] # list of (start_idx, end_idx)
run_start = None
# Step 2: 时序一致贪心对齐。
# run 内偏好与上一匹配帧目标序号连贯(0 <= delta <= neighbor_window+1
# 支持 ±1 邻接窗口/时序偏移对齐)的候选;无连贯候选时关闭旧 run。
# 这天然支持局部片段复用:同一 query 视频中多个复用片段各自形成独立 run。
frame_matches: list[tuple[bool, int, int]] = []
runs: list[tuple[int, int]] = []
run_start: Optional[int] = None
run_last_t: Optional[int] = None
gap_count = 0
for i, (is_match, _dist, _idx) in enumerate(frame_matches):
if is_match:
def _matching_count(a: int, b: int) -> int:
return sum(1 for k in range(a, b + 1) if frame_matches[k][0])
def _close_run(a: int, b: int) -> None:
if b >= a and _matching_count(a, b) >= min_consecutive:
runs.append((a, b))
for i in range(n):
cand = candidates[i]
if run_last_t is None:
chosen = cand[0] if cand else None
else:
chosen = next(
(c for c in cand if 0 <= c[0] - run_last_t <= neighbor_window + 1),
None,
)
if chosen is not None:
tidx, dist = chosen
frame_matches.append((True, dist, tidx))
if run_start is None:
run_start = i
gap_count = 0 # 重置间隙
gap_count = 0
run_last_t = tidx
else:
frame_matches.append((False, match_threshold + 1, -1))
if run_start is not None:
gap_count += 1
if gap_count > max_gap:
# 中断当前 run
run_end = i - gap_count # 最后一个匹配帧的索引
# 计算 run 内的实际匹配帧数(总跨度 - 间隙数)
total_gaps = sum(1 for k in range(run_start, run_end + 1) if not frame_matches[k][0])
matching_count = (run_end - run_start + 1) - total_gaps
if matching_count >= min_consecutive:
runs.append((run_start, run_end))
run_start = None
gap_count = 0
# 非匹配帧从 i-gap_count+1 开始,run 结束于其前一帧
_close_run(run_start, i - gap_count)
run_start, run_last_t, gap_count = None, None, 0
# 处理末尾 run
if run_start is not None:
last_idx = len(frame_matches) - 1
# 回退找到最后一个匹配帧的位置(跳过尾部非匹配帧)
last_idx = n - 1
while last_idx >= run_start and not frame_matches[last_idx][0]:
last_idx -= 1
if last_idx >= run_start:
# 计算 run 内的总间隙数
total_gaps = sum(1 for k in range(run_start, last_idx + 1) if not frame_matches[k][0])
matching_count = (last_idx - run_start + 1) - total_gaps
if matching_count >= min_consecutive:
runs.append((run_start, last_idx))
_close_run(run_start, last_idx)
# Step 3: 构建 DuplicateSegment
segments: list[DuplicateSegment] = []
for start, end in runs:
query_start = _get_start(query_chunks[start])
query_end = _get_end(query_chunks[end])
# 取目标范围(按最佳匹配的目标 chunk 时间范围)
target_indices = [frame_matches[k][2] for k in range(start, end + 1) if frame_matches[k][0]]
if target_indices:
t_min = min(target_indices)
t_max = max(target_indices)
target_start = _get_start(target_chunks[t_min])
target_end = _get_end(target_chunks[t_max])
else:
target_start = _get_start(target_chunks[0])
target_end = _get_end(target_chunks[-1])
avg_dist = sum(frame_matches[k][1] for k in range(start, end + 1)) / (end - start + 1)
t_min, t_max = min(target_indices), max(target_indices)
avg_dist = sum(frame_matches[k][1] for k in range(start, end + 1) if frame_matches[k][0]) / len(target_indices)
segments.append(
DuplicateSegment(
query_start_ms=query_start,
query_end_ms=query_end,
target_start_ms=target_start,
target_end_ms=target_end,
query_start_ms=_get(query_chunks[start], "start_time_ms"),
query_end_ms=_get(query_chunks[end], "end_time_ms"),
target_start_ms=_get(target_chunks[t_min], "start_time_ms"),
target_end_ms=_get(target_chunks[t_max], "end_time_ms"),
avg_distance=avg_dist,
)
)
@@ -423,7 +495,9 @@ def find_duplicate_segments(
class VideoDeduplicator:
"""Video deduplication using multiple fingerprint methods."""
PHASH_THRESHOLD = 8 # Issue #1658: pHash 汉明距离阈值由 10 收紧到 8,降低不同视频误判率
# Issue #1702: 阈值统一来源为模块常量 PHASH_THRESHOLD#1658 曾收紧到 8
# 后经 staging 真实同源/异源指纹分布重新校准,见 test_phash_threshold_calibration_1702)。
PHASH_THRESHOLD = PHASH_THRESHOLD
HISTOGRAM_THRESHOLD = 0.85
@staticmethod
@@ -447,27 +521,36 @@ class VideoDeduplicator:
# 单帧不视为坏指纹(短视频或抽帧不足)
if len(phashes) == 1:
return False
# 多帧但所有 phash 完全相同 → 黑屏/纯色视频
# Issue #1702: 旧逻辑"所有 phash 完全相同即判黑屏"会误杀短视频——
# 11s 视频只有几个不同镜头时,相邻 1s 采样帧可能 phash 完全一致(内容
# 连续但非黑屏)。黑屏的特征是「大量帧全部无内容」,要求至少 8 帧
# 且相同帧占比 >=80% 才判坏;短视频(<8 帧)只有真正单值时交给
# _bhattacharyya/融合分兜底,不因"帧都一样"直接跳过。
if len(phashes) < 8:
return False
unique = set(phashes)
if len(unique) == 1:
same_ratio = sum(1 for x in phashes if x == phashes[0]) / len(phashes)
if len(unique) == 1 and same_ratio >= 0.8:
return True
# 多帧但所有 phash 之间的汉明距离都极小(<3)→ 近似黑屏
# 多帧但所有唯一 phash 之间的汉明距离都极小(<3)且占比 >=80% → 近似黑屏
phash_list = list(unique)
if len(phash_list) >= 2:
all_distances = []
for i in range(len(phash_list)):
for j in range(i + 1, len(phash_list)):
all_distances.append(hamming_distance(phash_list[i], phash_list[j]))
if len(phash_list) >= 2 and same_ratio >= 0.8:
all_distances = [
hamming_distance(phash_list[i], phash_list[j])
for i in range(len(phash_list))
for j in range(i + 1, len(phash_list))
]
if all_distances and max(all_distances) < 3:
return True
return False
def compute_fingerprint(self, video_path: str) -> VideoFingerprint:
"""Compute video fingerprint using dynamic keyframe detection.
"""Compute video fingerprint using dense uniform sampling.
使用 detect_keyframe_timestamps() 检测内容感知关键帧,
在每个关键帧处取帧计算 pHash + color_histogram。
同时保留 MD5 计算和分片数据结构。
Issue #1702: 使用 sample_fingerprint_timestamps() 固定 1s 间隔密集均匀
采样(替代动态场景检测抽帧),保证两个同源视频复用片段的帧时刻天然
对齐;每帧取中心 90% 区域(center_crop_frame)计算 pHash + color_histogram
绕开 random_edge_crop 降重裁剪污染;MD5 仍基于原始帧。
"""
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
@@ -481,8 +564,8 @@ class VideoDeduplicator:
cap.release()
# 1. 检测关键帧时间戳
keyframe_times = detect_keyframe_timestamps(video_path)
# 1. 固定间隔密集采样(Issue #1702:替代动态场景检测,保证跨视频时序对齐)
keyframe_times = sample_fingerprint_timestamps(duration)
if not keyframe_times:
return VideoFingerprint(
@@ -506,12 +589,15 @@ class VideoDeduplicator:
if not ret:
continue
# MD5 计算
# MD5 计算(基于原始帧,指纹文件级去重不受裁剪影响)
_, buffer = cv2.imencode(".jpg", frame)
md5_hash.update(buffer)
phash = compute_phash(frame)
hist = compute_color_histogram(frame)
# Issue #1702: pHash / 颜色直方图基于中心 90% 区域,绕开 random_edge_crop
# 降重裁剪对指纹的污染(降重只服务外部平台,不污染自查重)。
fp_frame = center_crop_frame(frame)
phash = compute_phash(fp_frame)
hist = compute_color_histogram(fp_frame)
# 计算分片时间范围(从前一个关键帧到下一个关键帧的中点)
prev_boundary = keyframe_times[i - 1] * 1000 if i > 0 else 0
@@ -564,12 +650,22 @@ class VideoDeduplicator:
@staticmethod
def _bhattacharyya_coefficient(hist_a: list[float], hist_b: list[float]) -> float:
"""Bhattacharyya 系数:Σ √(a[i] * b[i]),范围 [0, 1]1=完全相同。"""
"""Bhattacharyya 系数(概率分布版,范围 [0,1]1=完全相同
Issue #1702: compute_color_histogram 输出 3 通道拼接、每通道独立 NORM_L1
(单通道 Σ=1,三通道拼接向量 Σ=3)。旧实现直接 Σ√(a*b) 对三通道拼接向量
算出 ~3(旧 L2 归一化更是算出 ~14.9),不是合法的概率系数。
这里按两个直方图各自的总量归一:BC = Σ√(a*b) / √(Σa·Σb)。
- 单通道概率分布(Σa=Σb=1):分母 1,与旧测试/教科书定义一致;
- 三通道拼接(Σa=Σb=3):分母 3,结果在 [0,1]。
"""
min_len = min(len(hist_a), len(hist_b))
a = hist_a[:min_len]
b = hist_b[:min_len]
# 纯标准库计算(不依赖 numpy);max(0.0, ...) 防御上游异常负值导致 sqrt domain error
return float(sum(math.sqrt(max(0.0, ai * bi)) for ai, bi in zip(a, b, strict=False)))
a = [max(0.0, float(x)) for x in hist_a[:min_len]]
b = [max(0.0, float(x)) for x in hist_b[:min_len]]
# max(0.0, ...) 防御上游异常负值导致 sqrt domain error
coeff = sum(math.sqrt(ai * bi) for ai, bi in zip(a, b, strict=False))
norm = math.sqrt(sum(a) * sum(b))
return float(coeff / norm) if norm > 0 else 0.0
@staticmethod
def _compute_histogram_similarity(
@@ -611,6 +707,72 @@ class VideoDeduplicator:
hist_similarity = VideoDeduplicator._compute_histogram_similarity(hist_a, hist_b) if hist_b else 0.5
return PHASH_WEIGHT * phash_similarity + HISTOGRAM_WEIGHT * hist_similarity
@staticmethod
def _evaluate_candidate(
fingerprint: VideoFingerprint,
existing_phashes: list[str],
existing_histograms: list,
existing_chunk_objects: list,
*,
query_duration_sec: float,
) -> dict:
"""评估新视频指纹与单个候选视频的相似度(Issue #1702 共享逻辑)。
指标:
- min_distances / frame_match_rate:每个新分片到候选视频全局最近邻的汉明距离,
分母取两视频分片数的较小值(支持局部片段复用:短视频复用长视频片段时不被长视频分母稀释)。
- temporal_coverage:时序一致连续匹配片段总时长 / 新视频时长(局部复用主指标)。
- fusionpHash 中位数距离 + 颜色直方图的加权融合分。
Returns:
{frame_match_rate, temporal_coverage, segments, median_distance,
fusion, matching_frames, min_distances}
"""
query_phashes = fingerprint.keyframe_phashes or []
if not query_phashes or not existing_phashes:
return {
"frame_match_rate": 0.0,
"temporal_coverage": 0.0,
"segments": [],
"median_distance": 64,
"fusion": 0.0,
"matching_frames": 0,
"min_distances": [],
}
min_distances = [min(hamming_distance(ph, ep) for ep in existing_phashes) for ph in query_phashes]
matching_frames = sum(1 for d in min_distances if d <= PHASH_THRESHOLD)
# 分母取 min(两视频分片数):局部复用时(如 B 的 5 片复用 A 9 片中的若干片)
# 命中帧占比不因候选视频更长而被稀释。
frame_match_rate = matching_frames / min(len(query_phashes), len(existing_phashes))
segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects)
duration_ms = query_duration_sec * 1000 if query_duration_sec else 0
if duration_ms > 0 and segments:
covered_ms = sum(s.query_end_ms - s.query_start_ms for s in segments)
temporal_coverage = min(covered_ms / duration_ms, 1.0)
elif matching_frames > 0:
# 无连续片段(时序连贯性不足)时,按匹配帧占比估计覆盖:
# 密集 1s 采样下每个分片≈1s 等权时间片,匹配帧数≈命中秒数。
temporal_coverage = min(frame_match_rate, 1.0)
else:
temporal_coverage = 0.0
median_distance = statistics.median(min_distances) if min_distances else 64
fusion = VideoDeduplicator._compute_fusion_score(
median_distance, fingerprint.color_histograms, existing_histograms
)
return {
"frame_match_rate": frame_match_rate,
"temporal_coverage": temporal_coverage,
"segments": segments,
"median_distance": median_distance,
"fusion": fusion,
"matching_frames": matching_frames,
"min_distances": min_distances,
}
def check_duplicate(
self,
fingerprint: VideoFingerprint,
@@ -620,6 +782,7 @@ class VideoDeduplicator:
scope: str = "project",
user_id: str = "",
duration_sec: float = 0,
exclude_video_id: str | None = None,
) -> Optional[dict]:
"""检查视频是否与已有视频重复。
@@ -636,6 +799,9 @@ class VideoDeduplicator:
scope: "project" 项目内查重(默认),"user" 跨项目全局查重
user_id: 用户 IDscope="user" 时使用)
duration_sec: 视频时长(秒),用于时长预过滤 ±15%
exclude_video_id: 排除的视频 ID(查重自身时用)。recompute-dedup
重算时视频记录已存在,不排除会自匹配(距离 0 分最高)导致
duplicate_of 指向自己(Issue #1702 连带修复)。
Returns:
重复信息字典(含 duplicate, duplicate_of, reason, similarity, duplicate_segments),
@@ -643,13 +809,22 @@ class VideoDeduplicator:
"""
video_repo = SQLAlchemyGeneratedVideoRepository(session)
if scope == "user" and user_id:
dur_min = duration_sec * 0.85 if duration_sec > 0 else 0
dur_max = duration_sec * 1.15 if duration_sec > 0 else 0
existing_videos = video_repo.list_by_user(user_id, duration_min=dur_min, duration_max=dur_max)
# Issue #1702: 不做 ±15% 时长预过滤。旧逻辑按 duration_sec 缩小候选窗口,
# 但局部片段复用的两个视频时长必然不同(证据视频 20s vs 11s,差 42%),
# ±15% 窗口让同源视频互相不可见 → is_duplicate 恒 False。
# 全量遍历同用户视频(与 compute_duplicate_rate 口径一致),异源视频由
# fusion/temporal_coverage 阈值天然过滤(校准:异源最小汉明距离 24)。
existing_videos = video_repo.list_by_user(user_id)
else:
existing_videos = video_repo.list_by_project(project_id)
best_score = 0.0
best_result: Optional[dict] = None
for existing in existing_videos:
# 排除自身(recompute 时当前视频已在候选列表里,否则自匹配距离 0 必最高分)
if exclude_video_id and existing.id == exclude_video_id:
continue
if not existing.video_fingerprint:
continue
@@ -677,61 +852,70 @@ class VideoDeduplicator:
if not existing_phashes:
continue
# 计算每个新关键帧到已有关键帧的最小汉明距离
min_distances = []
for phash in fingerprint.keyframe_phashes:
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
min_distances.append(min(distances))
# 帧匹配比例检查
matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD)
match_ratio = matching_frames / len(min_distances) if min_distances else 0
if match_ratio < MATCH_RATIO_THRESHOLD:
continue
# 中位数距离
median_distance = statistics.median(min_distances) if min_distances else 64
if median_distance >= self.PHASH_THRESHOLD:
continue
# 直方图融合(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表)
# 直方图 / 分片对象(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表)
if chunk_data:
existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")]
existing_chunk_objects = chunk_data
else:
existing_histograms = ef.get("color_histograms") or []
existing_chunk_objects = [
{"phash_binary": pp, "start_time_ms": 0, "end_time_ms": 0} for pp in existing_phashes
]
combined_score = self._compute_fusion_score(
median_distance, fingerprint.color_histograms, existing_histograms
# Issue #1702: 统一评估每个候选(含局部片段复用),不再用
# "frame_match_rate<0.7 整条跳过" 的硬门槛——局部复用(如 B 结尾 2s
# ≈ A 中间 2s)帧比例天然低,但 coverage 能检出。
ev = self._evaluate_candidate(
fingerprint,
existing_phashes,
existing_histograms,
existing_chunk_objects,
query_duration_sec=fingerprint.duration,
)
logger.debug(
"check_duplicate candidate=%s min_distances=%s frame_match_rate=%.3f "
"temporal_coverage=%.3f median=%.1f fusion=%.3f segments=%d",
existing.id,
ev["min_distances"],
ev["frame_match_rate"],
ev["temporal_coverage"],
ev["median_distance"],
ev["fusion"],
len(ev["segments"]),
)
if combined_score < DUPLICATE_THRESHOLD:
continue
# 滑动窗口时序匹配:获取具体重复片段
existing_chunk_objects = (
chunk_data
if chunk_data
else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes]
# 全片重复判定:融合分过阈 且(帧匹配比例 >=70% 或 局部覆盖 >=50%
is_full_duplicate = ev["fusion"] >= DUPLICATE_THRESHOLD and (
ev["frame_match_rate"] >= MATCH_RATIO_THRESHOLD or ev["temporal_coverage"] >= PARTIAL_COVERAGE_THRESHOLD
)
segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects)
return {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "phash_histogram_fusion",
"similarity": combined_score,
"duplicate_segments": [
{
"query_start_ms": s.query_start_ms,
"query_end_ms": s.query_end_ms,
"target_start_ms": s.target_start_ms,
"target_end_ms": s.target_end_ms,
"avg_distance": round(s.avg_distance, 2),
}
for s in segments
],
}
if is_full_duplicate and ev["fusion"] > best_score:
best_score = ev["fusion"]
best_result = {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "phash_histogram_fusion",
"similarity": ev["fusion"],
"duplicate_segments": [
{
"query_start_ms": s.query_start_ms,
"query_end_ms": s.query_end_ms,
"target_start_ms": s.target_start_ms,
"target_end_ms": s.target_end_ms,
"avg_distance": round(s.avg_distance, 2),
}
for s in ev["segments"]
],
}
if best_result:
return best_result
logger.info(
"check_duplicate no match (project=%s scope=%s): %d candidates evaluated, best_fusion=%.3f",
project_id,
scope,
len(existing_videos),
best_score,
)
return None
def check_batch_duplicate(
@@ -763,6 +947,9 @@ class VideoDeduplicator:
video_repo = SQLAlchemyGeneratedVideoRepository(session)
batch_videos = video_repo.list_by_batch(batch_id)
best_score = 0.0
best_result: Optional[dict] = None
for existing in batch_videos:
if existing.id == current_video_id:
continue
@@ -796,59 +983,59 @@ class VideoDeduplicator:
if not existing_phashes:
continue
min_distances = []
for phash in fingerprint.keyframe_phashes:
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
min_distances.append(min(distances))
# 帧匹配比例检查
matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD)
match_ratio = matching_frames / len(min_distances) if min_distances else 0
if match_ratio < MATCH_RATIO_THRESHOLD:
continue
median_distance = statistics.median(min_distances) if min_distances else 64
if median_distance >= self.PHASH_THRESHOLD:
continue
# 直方图融合(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表)
if chunk_data:
existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")]
existing_chunk_objects = chunk_data
else:
existing_histograms = ef.get("color_histograms") or []
existing_chunk_objects = [
{"phash_binary": pp, "start_time_ms": 0, "end_time_ms": 0} for pp in existing_phashes
]
combined_score = self._compute_fusion_score(
median_distance, fingerprint.color_histograms, existing_histograms
ev = self._evaluate_candidate(
fingerprint,
existing_phashes,
existing_histograms,
existing_chunk_objects,
query_duration_sec=fingerprint.duration,
)
logger.debug(
"check_batch_duplicate candidate=%s min_distances=%s frame_match_rate=%.3f "
"temporal_coverage=%.3f median=%.1f fusion=%.3f segments=%d",
existing.id,
ev["min_distances"],
ev["frame_match_rate"],
ev["temporal_coverage"],
ev["median_distance"],
ev["fusion"],
len(ev["segments"]),
)
if combined_score < DUPLICATE_THRESHOLD:
continue
# 滑动窗口时序匹配
existing_chunk_objects = (
chunk_data
if chunk_data
else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes]
is_full_duplicate = ev["fusion"] >= DUPLICATE_THRESHOLD and (
ev["frame_match_rate"] >= MATCH_RATIO_THRESHOLD or ev["temporal_coverage"] >= PARTIAL_COVERAGE_THRESHOLD
)
segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects)
return {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "batch_phash_histogram_fusion",
"similarity": combined_score,
"duplicate_segments": [
{
"query_start_ms": s.query_start_ms,
"query_end_ms": s.query_end_ms,
"target_start_ms": s.target_start_ms,
"target_end_ms": s.target_end_ms,
"avg_distance": round(s.avg_distance, 2),
}
for s in segments
],
}
if is_full_duplicate and ev["fusion"] > best_score:
best_score = ev["fusion"]
best_result = {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "batch_phash_histogram_fusion",
"similarity": ev["fusion"],
"duplicate_segments": [
{
"query_start_ms": s.query_start_ms,
"query_end_ms": s.query_end_ms,
"target_start_ms": s.target_start_ms,
"target_end_ms": s.target_end_ms,
"avg_distance": round(s.avg_distance, 2),
}
for s in ev["segments"]
],
}
if best_result:
return best_result
logger.info("check_batch_duplicate no match (batch=%s): best_fusion=%.3f", batch_id, best_score)
return None
def compute_duplicate_rate(
@@ -897,8 +1084,7 @@ class VideoDeduplicator:
max_duplicate_rate = 0.0
max_visual_similarity = 0.0
match_count = 0
total_duration_ms = fingerprint.duration if fingerprint.duration else 0
evaluated = 0
for existing in existing_videos:
if current_video_id and existing.id == current_video_id:
@@ -933,57 +1119,63 @@ class VideoDeduplicator:
if not existing_phashes or not fingerprint.keyframe_phashes:
continue
min_distances = []
for phash in fingerprint.keyframe_phashes:
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
min_distances.append(min(distances))
# frame_match_rate
total_frames = len(min_distances)
if total_frames == 0:
continue
matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD)
frame_match_rate = matching_frames / total_frames
# 帧匹配比例太低则跳过
if frame_match_rate < 0.3:
continue
# temporal_coverage_rate via find_duplicate_segments
existing_chunk_objects = (
chunk_data
if chunk_data
else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes]
)
segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects)
if total_duration_ms > 0 and segments:
covered_ms = sum(s.query_end_ms - s.query_start_ms for s in segments)
temporal_coverage_rate = min(covered_ms / total_duration_ms, 1.0)
else:
temporal_coverage_rate = 0.0
# duplicate_rate = 0.4 * frame_match_rate + 0.6 * temporal_coverage_rate
dup_rate = (frame_match_rate * 0.4 + temporal_coverage_rate * 0.6) * 100
# visual_similarity (融合相似度,归一化 0~1)
median_distance = statistics.median(min_distances) if min_distances else 64
# 直方图 / 分片对象(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表)
if chunk_data:
existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")]
existing_chunk_objects = chunk_data
else:
# JSON NULL 显式回退空列表
existing_histograms = ef.get("color_histograms") or []
existing_chunk_objects = [
{"phash_binary": pp, "start_time_ms": 0, "end_time_ms": 0} for pp in existing_phashes
]
visual_sim = self._compute_fusion_score(median_distance, fingerprint.color_histograms, existing_histograms)
# Issue #1702: 统一评估;frame_match_rate 分母为 min(两视频分片数),
# temporal_coverage 时长量纲在 _evaluate_candidate 内统一为毫秒。
ev = self._evaluate_candidate(
fingerprint,
existing_phashes,
existing_histograms,
existing_chunk_objects,
query_duration_sec=fingerprint.duration,
)
evaluated += 1
logger.debug(
"compute_duplicate_rate candidate=%s min_distances=%s frame_match_rate=%.3f "
"temporal_coverage=%.3f median=%.1f fusion=%.3f segments=%d",
existing.id,
ev["min_distances"],
ev["frame_match_rate"],
ev["temporal_coverage"],
ev["median_distance"],
ev["fusion"],
len(ev["segments"]),
)
# 判定是否为重复(融合分数超过阈值)
if visual_sim >= DUPLICATE_THRESHOLD:
# Issue #1702: 去掉 "frame_match_rate<0.3 整条跳过" 硬门槛——
# 局部片段复用帧比例天然低;coverage 为主指标,0 匹配自然得 0 分。
# duplicate_rate = 0.4 * frame_match_rate + 0.6 * temporal_coverage
dup_rate = (min(ev["frame_match_rate"], 1.0) * 0.4 + ev["temporal_coverage"] * 0.6) * 100
# 全片重复计数与 check_duplicate 判定口径一致
if ev["fusion"] >= DUPLICATE_THRESHOLD and (
ev["frame_match_rate"] >= MATCH_RATIO_THRESHOLD or ev["temporal_coverage"] >= PARTIAL_COVERAGE_THRESHOLD
):
match_count += 1
if dup_rate > max_duplicate_rate:
max_duplicate_rate = dup_rate
max_visual_similarity = visual_sim
max_visual_similarity = ev["fusion"]
logger.info(
"compute_duplicate_rate done (project=%s scope=%s): evaluated=%d max_rate=%.2f%% "
"max_visual_sim=%.3f matches=%d",
project_id,
scope,
evaluated,
max_duplicate_rate,
max_visual_similarity,
match_count,
)
return {
"duplicate_rate": round(max(max_duplicate_rate, 0.0), 2),
"visual_similarity": round(max_visual_similarity, 4),
@@ -999,18 +1191,20 @@ def _save_fingerprint_chunks(
session: Session,
) -> None:
"""将指纹分片数据批量写入 video_fingerprint_chunks 表。幂等:已有数据时跳过。"""
# 幂等检查:已有分片数据则跳过
existing_count = (
session.query(VideoFingerprintChunkModel).filter(VideoFingerprintChunkModel.video_id == video_id).count()
)
if existing_count > 0:
logger.debug("Fingerprint chunks already exist for video %s (%d chunks), skipping", video_id, existing_count)
return
if not fingerprint.chunks:
logger.warning("No chunks in fingerprint for video %s, skipping chunk save", video_id)
return
# Issue #1702: recompute-dedup 重算时指纹算法已变(中心裁剪 + 新阈值),
# 旧分片必须替换而非跳过(旧实现"有数据就跳过"导致重算不刷新分片表)。
deleted = (
session.query(VideoFingerprintChunkModel)
.filter(VideoFingerprintChunkModel.video_id == video_id)
.delete(synchronize_session=False)
)
if deleted:
logger.info("Replaced %d stale fingerprint chunks for video %s", deleted, video_id)
chunk_models = fingerprint.to_chunk_models(video_id, project_id, user_id)
session.bulk_save_objects(chunk_models)
logger.info("Saved %d fingerprint chunks for video %s", len(chunk_models), video_id)
@@ -1032,9 +1226,17 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
raise ValueError(f"Generated video {generated_video_id} not found")
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
storage_service.download_file(
f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path
)
# Issue #1702: recompute 走的是 OSS 重新下载路径(正常生成流程用本地渲染文件,
# 不经此任务)。成片真实 OSS key 是生成时的
# generated/projects/{pid}/tasks/{task_id}/rendered_*.mp4(见 generation.py
# _upload_and_record),旧代码硬编码 projects/{pid}/generated/{vid}/{vid}.mp4
# 这个从不存在的 key,导致所有 recompute 任务下载 404、查重数据永远无法重算。
# 优先从 file_url 解析真实 key,旧 key 模式仅作回退。
download_key = getattr(video, "file_url", "") or ""
if not download_key:
download_key = f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4"
logger.warning("video %s has no file_url, falling back to legacy key %s", generated_video_id, download_key)
storage_service.download_file(download_key, local_path)
fingerprint = deduplicator.compute_fingerprint(local_path)
@@ -1045,7 +1247,10 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
session,
scope="user",
user_id=video.user_id,
duration_sec=fingerprint.duration / 1000 if fingerprint.duration else 0,
# Issue #1702: fingerprint.duration 单位已经是秒,旧代码 /1000 导致
# ±15% 时长预过滤窗口缩到 ~0.013sscope=user 的跨项目查重永远返回 None。
duration_sec=fingerprint.duration if fingerprint.duration else 0,
exclude_video_id=generated_video_id,
)
video.video_fingerprint = fingerprint.to_dict()
@@ -92,7 +92,8 @@ def create_video_record_and_dedup(
logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err)
# (a) 历史成片查重(跨项目全局 + 时长预过滤)
duration_sec = fingerprint.duration / 1000 if fingerprint.duration else 0
# Issue #1702: fingerprint.duration 单位是秒,旧代码 /1000 让时长预过滤失效
duration_sec = fingerprint.duration if fingerprint.duration else 0
duplicate_result = deduplicator.check_duplicate(
fingerprint,
project_id,
@@ -100,6 +101,7 @@ def create_video_record_and_dedup(
scope="user",
user_id=user_id,
duration_sec=duration_sec,
exclude_video_id=video_id,
)
# (b) 批次内查重(仅当有 batch_id 时)
-103
View File
@@ -1,103 +0,0 @@
#!/bin/bash
# ============================================
# 基础镜像同步脚本 - 从公共镜像源同步到私有ACR
# 用法:
# ACR_USERNAME=xxx ACR_PASSWORD=yyy bash scripts/ci/sync_base_images.sh
# ============================================
set -euo pipefail
ACR_REGISTRY="${ACR_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
ACR_USERNAME="${ACR_USERNAME:-}"
ACR_PASSWORD="${ACR_PASSWORD:-}"
SOURCE_PREFIX="${SOURCE_PREFIX:-docker.m.daocloud.io/library}"
# 需要同步的镜像列表 (源镜像名:tag => ACR目标名:tag)
IMAGES=(
"python:3.12-slim-bookworm"
"python:3.12-slim"
"node:20"
"nginx:alpine"
)
echo "============================================"
echo " 基础镜像同步到 ACR"
echo " ACR: $ACR_REGISTRY"
echo " 源: $SOURCE_PREFIX"
echo "============================================"
echo ""
# 登录 ACR
if [ -n "$ACR_PASSWORD" ] && [ -n "$ACR_USERNAME" ]; then
echo "登录 ACR..."
ACR_HOST=$(echo "$ACR_REGISTRY" | cut -d/ -f1)
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_HOST" -u "$ACR_USERNAME" --password-stdin
echo "ACR 登录成功"
echo ""
fi
success=0
failed=0
for image in "${IMAGES[@]}"; do
source_image="${SOURCE_PREFIX}/${image}"
target_image="${ACR_REGISTRY}/base/${image}"
echo "--- 同步: $image ---"
echo " 源: $source_image"
echo " 目标: $target_image"
# Pull 源镜像(带重试)
pulled=0
for attempt in 1 2 3; do
echo " Pull 尝试 $attempt/3..."
if docker pull "$source_image"; then
pulled=1
break
fi
echo " Pull 失败,5s 后重试..."
sleep 5
done
if [ "$pulled" -eq 0 ]; then
echo " ❌ Pull 失败: $image"
failed=$((failed + 1))
continue
fi
# Tag
docker tag "$source_image" "$target_image"
echo " Tag 完成"
# Push 到 ACR
pushed=0
for attempt in 1 2 3; do
echo " Push 尝试 $attempt/3..."
if docker push "$target_image"; then
pushed=1
break
fi
echo " Push 失败,5s 后重试..."
sleep 5
done
if [ "$pushed" -eq 1 ]; then
echo " ✅ 同步成功: $image"
success=$((success + 1))
else
echo " ❌ Push 失败: $image"
failed=$((failed + 1))
fi
echo ""
done
echo "============================================"
echo " 同步完成"
echo " 成功: $success"
echo " 失败: $failed"
echo "============================================"
if [ "$failed" -gt 0 ]; then
exit 1
fi
-157
View File
@@ -1,157 +0,0 @@
#!/bin/bash
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
set -eu
echo "=== CI Validate: 代码质量与安全扫描 ==="
# --- 密钥检测 ---
echo ""
echo "=== [1/6] Secret detection (detect-secrets) ==="
python3 -m pip install -q detect-secrets
detect-secrets --version
detect-secrets scan \
--all-files \
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
--disable-plugin Base64HighEntropyString \
--disable-plugin HexHighEntropyString \
--disable-plugin BasicAuthDetector \
--disable-plugin KeywordDetector \
--disable-plugin IPPublicDetector \
> /tmp/secrets-scan.json 2>&1
FOUND=$(python3 -c "
import json
try:
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
results = data.get('results', {})
total = sum(len(v) for v in results.values())
print(total)
except Exception:
print('error')
")
echo "Secrets detected: $FOUND"
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
echo ""
echo "=== Secret details ==="
python3 -c "
import json
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
for fpath, items in data.get('results', {}).items():
for item in items:
line = item.get('line_number', '?')
stype = item.get('type', '?')
hashed = item.get('hashed_secret', '')[:16]
print(f' {fpath}:{line} [{stype}] {hashed}...')
"
echo ""
echo "ERROR: Potential secrets detected in code!"
exit 1
fi
echo "✅ Secret scan passed"
# --- 代码质量检查(全量,PR 和 push 统一标准)---
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
echo ""
echo "=== [2/6] Code quality checks (full scan) ==="
SCAN_MODE="full"
echo "Full scan mode"
python3 -m compileall -q alembic apps packages tests scripts
python3 -m black --check --fast alembic apps packages tests scripts
python3 -m isort --check-only alembic apps packages tests scripts
python3 -m ruff check apps packages tests --statistics
echo "✅ Code quality checks passed"
# --- Bandit 安全扫描(仅告警) ---
echo ""
echo "=== [3/6] Security scan (bandit, advisory only) ==="
set +e
bandit -r apps packages -q -ll
BANDIT_EXIT=$?
set -e
if [ "$BANDIT_EXIT" -ne 0 ]; then
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
else
echo "✅ Bandit security scan passed"
fi
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
echo ""
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
python3 -m pip install -q pip-audit
pip-audit --version
EXIT_CODE=0
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
if [ -f "$req_file" ]; then
echo "--- Scanning $req_file ---"
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
echo ""
fi
done
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
# --- Vulture 死代码检测(仅告警) ---
echo ""
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
set +e
python3 -m pip install -q vulture
vulture --version
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
echo ""
vulture apps packages scripts \
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
--min-confidence 70 \
2>&1 | sort -t'(' -k2 -rn | head -80
echo ""
echo "=== vulture scan summary ==="
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
echo "建议:定期人工审查高置信度(>=90%)条目"
set -e
# --- CI脚本语法校验 ---
echo ""
echo "=== [6/6] CI & shell scripts syntax validation ==="
SYNTAX_ERROR=0
# 检查所有 CI shell 脚本
for script in scripts/ci/*.sh; do
if [ -f "$script" ]; then
if ! bash -n "$script" 2>&1; then
echo "❌ 语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
# 检查所有 CI Python 脚本语法
for script in scripts/ci/*.py; do
if [ -f "$script" ]; then
if ! python3 -m py_compile "$script" 2>&1; then
echo "❌ Python语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
# 检查 .gitea/workflows 下的脚本(如果有)
for script in .gitea/workflows/*.sh; do
if [ -f "$script" ]; then
if ! bash -n "$script" 2>&1; then
echo "❌ 语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
if [ "$SYNTAX_ERROR" -ne 0 ]; then
echo "❌ CI脚本语法校验失败,见上方错误"
exit 1
fi
echo "✅ All CI scripts syntax OK"
echo ""
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
+507
View File
@@ -0,0 +1,507 @@
"""Issue #1677 多视频批量生成 — 变体独立配置与批量预览/批量生成测试。
覆盖:
- 批量预览:preview_count=N 一次创建 N 个独立任务,返回变体数组
- 变体克隆链路:N 个预览/正式任务各自关联独立克隆 plan
- 变体独立配置:titles[]/voice_library_ids[]/cover_urls[] 按变体注入
- 长度校验:数组长度必须为 1 或 N(共用或独立),非法长度报错
- N=1 向后兼容:旧字段单值行为不变
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from app.core.task_enqueue import GlobalQueueFull, UserPendingLimitExceeded
from app.schemas.generation_task import (
BatchPreviewGenerationTaskResponse,
CreateGenerationTaskRequest,
CreatePreviewGenerationTaskRequest,
)
from packages.domain import GenerationTask
from packages.domain.generation_task import GenerationTaskStatus
# ════════════════════════════════════════════════════════════════════════════
# 辅助构造
# ════════════════════════════════════════════════════════════════════════════
def _make_user(user_id="test_user_001"):
mock_user = MagicMock()
mock_user.id = user_id
auth = MagicMock()
auth.user = mock_user
return auth
def _make_task(task_id=None, status=GenerationTaskStatus.PENDING, source_plan_id=None):
task = GenerationTask.create(
project_id="",
asset_library_id="",
template_id="tpl_001",
asset_ids=["asset_1"],
)
if task_id:
task.id = task_id
task.status = status
task.is_preview = True
task.source_edit_plan_id = source_plan_id or ""
task.voice_library_id = ""
task.title_config = {}
task.cover_url = ""
return task
def _make_preview_request(**kwargs):
defaults = {
"template_id": "tpl_001",
"asset_ids": ["asset_1", "asset_2"],
}
defaults.update(kwargs)
return CreatePreviewGenerationTaskRequest(**defaults)
def _repo_mock():
repo = MagicMock()
repo.count_pending_by_user.return_value = 0
repo.count_pending_total.return_value = 0
repo.get.side_effect = lambda tid: None
return repo
# ════════════════════════════════════════════════════════════════════════════
# Schema 校验:变体数组长度
# ════════════════════════════════════════════════════════════════════════════
class TestVariantArrayValidation:
"""变体数组字段长度校验。"""
def test_preview_titles_length_matches_count(self):
"""titles 长度 = preview_count 合法"""
req = _make_preview_request(preview_count=3, titles=["标题A", "标题B", "标题C"])
assert len(req.titles) == 3
def test_preview_titles_single_shared(self):
"""titles 长度 1 = 所有变体共用,合法"""
req = _make_preview_request(preview_count=3, titles=["共用标题"])
assert req.titles == ["共用标题"]
def test_preview_titles_wrong_length_raises(self):
"""titles 长度 2 与 preview_count=3 不匹配 → 报错"""
with pytest.raises(ValueError, match="titles"):
_make_preview_request(preview_count=3, titles=["A", "B"])
def test_preview_voice_ids_wrong_length_raises(self):
"""voice_library_ids 长度非法 → 报错"""
from pydantic import ValidationError
with pytest.raises(ValidationError, match="voice_library_ids"):
_make_preview_request(preview_count=4, voice_library_ids=["v1", "v2"])
def test_preview_empty_arrays_ok(self):
"""空数组(回退单值字段)合法"""
req = _make_preview_request(preview_count=3)
assert req.titles == []
assert req.voice_library_ids == []
assert req.cover_urls == []
def test_generation_titles_length_matches_count(self):
"""正式生成 titles 长度 = count 合法"""
req = CreateGenerationTaskRequest(
template_id="tpl_1",
asset_ids=["a1"],
count=3,
titles=["A", "B", "C"],
)
assert len(req.titles) == 3
def test_generation_arrays_wrong_length_raises(self):
"""正式生成 cover_urls 长度与 count 不匹配 → 报错"""
from pydantic import ValidationError
with pytest.raises(ValidationError, match="cover_urls"):
CreateGenerationTaskRequest(
template_id="tpl_1",
asset_ids=["a1"],
count=3,
cover_urls=["c1", "c2"],
)
def test_generation_single_count_no_arrays(self):
"""N=1 且不传数组:完全旧行为"""
req = CreateGenerationTaskRequest(template_id="tpl_1", asset_ids=["a1"])
assert req.count == 1
assert req.titles == []
assert req.voice_library_ids == []
assert req.cover_urls == []
# ════════════════════════════════════════════════════════════════════════════
# 批量预览路由
# ════════════════════════════════════════════════════════════════════════════
class TestBatchPreviewRoute:
"""POST /preview 批量变体。"""
def test_preview_count_1_returns_single_item_array(self):
"""N=1 返回 items 长度 1 的批量响应(结构统一)"""
from app.api.routes.generation_preview import create_preview_generation_task
task = _make_task(task_id="task_1")
repo = _repo_mock()
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
resp = create_preview_generation_task(
_make_preview_request(preview_count=1),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
assert isinstance(resp, BatchPreviewGenerationTaskResponse)
assert resp.total == 1
assert len(resp.items) == 1
assert resp.items[0].task_id == "task_1"
assert resp.items[0].variant_index == 0
def test_preview_count_3_creates_three_independent_tasks(self):
"""N=3 创建 3 个独立任务,返回 3 个变体,task_id 各不相同"""
from app.api.routes.generation_preview import create_preview_generation_task
tasks = [_make_task(task_id=f"task_{i}") for i in range(3)]
repo = _repo_mock()
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.side_effect = tasks
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
resp = create_preview_generation_task(
_make_preview_request(preview_count=3),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
assert resp.total == 3
task_ids = [item.task_id for item in resp.items]
assert task_ids == ["task_0", "task_1", "task_2"]
assert len(set(task_ids)) == 3
for i, item in enumerate(resp.items):
assert item.variant_index == i
def test_preview_count_3_clones_three_variant_plans(self):
"""有源 plan 时,N=3 克隆 3 个独立变体 plan(预览全部克隆,不用源 plan)"""
from app.api.routes.generation_preview import create_preview_generation_task
tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)]
repo = _repo_mock()
cloned_plan_ids = ["clone_1", "clone_2", "clone_3"]
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.side_effect = tasks
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
clone_results = [MagicMock(id=pid) for pid in cloned_plan_ids]
MockPlanSvc.return_value.clone_plan_for_variant.side_effect = clone_results
create_preview_generation_task(
_make_preview_request(preview_count=3),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
# 克隆被调用 3 次
assert MockPlanSvc.return_value.clone_plan_for_variant.call_count == 3
# 每个任务关联到不同的克隆 plan
for i, task in enumerate(tasks):
assert task.source_edit_plan_id == cloned_plan_ids[i]
def test_preview_variant_titles_injected_per_variant(self):
"""titles[] 按变体注入 title_config.text"""
from app.api.routes.generation_preview import create_preview_generation_task
tasks = [_make_task(task_id=f"task_{i}") for i in range(3)]
repo = _repo_mock()
captured_commands = []
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
def _execute(cmd):
captured_commands.append(cmd)
return tasks[len(captured_commands) - 1]
MockUC.return_value.execute.side_effect = _execute
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
create_preview_generation_task(
_make_preview_request(
preview_count=3,
title_config={"font": "黑体", "position": "bottom"},
titles=["标题A", "标题B", "标题C"],
),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
assert len(captured_commands) == 3
assert captured_commands[0].title_config["text"] == "标题A"
assert captured_commands[1].title_config["text"] == "标题B"
assert captured_commands[2].title_config["text"] == "标题C"
# 样式全局共用
assert all(c.title_config["font"] == "黑体" for c in captured_commands)
def test_preview_shared_title_when_single_length(self):
"""titles 长度 1 = 所有变体共用同一标题"""
from app.api.routes.generation_preview import create_preview_generation_task
tasks = [_make_task(task_id=f"task_{i}") for i in range(3)]
repo = _repo_mock()
captured = []
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
def _execute(cmd):
captured.append(cmd)
return tasks[len(captured) - 1]
MockUC.return_value.execute.side_effect = _execute
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
create_preview_generation_task(
_make_preview_request(preview_count=3, titles=["共用标题"]),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
assert all(c.title_config["text"] == "共用标题" for c in captured)
def test_preview_independent_voice_per_variant(self):
"""voice_library_ids[] 按变体注入独立配音"""
from app.api.routes.generation_preview import create_preview_generation_task
tasks = [_make_task(task_id=f"task_{i}") for i in range(3)]
repo = _repo_mock()
captured = []
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
def _execute(cmd):
captured.append(cmd)
return tasks[len(captured) - 1]
MockUC.return_value.execute.side_effect = _execute
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
create_preview_generation_task(
_make_preview_request(
preview_count=3,
voice_library_ids=["voice_a", "voice_b", "voice_c"],
),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
assert [c.voice_library_id for c in captured] == ["voice_a", "voice_b", "voice_c"]
def test_preview_voice_fallback_to_single_field(self):
"""voice_library_ids 为空时回退 voice_library_id 单值字段(向后兼容)"""
from app.api.routes.generation_preview import create_preview_generation_task
task = _make_task(task_id="task_1")
repo = _repo_mock()
captured = []
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
def _execute(cmd):
captured.append(cmd)
return task
MockUC.return_value.execute.side_effect = _execute
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
create_preview_generation_task(
_make_preview_request(voice_library_id="legacy_voice"),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
assert captured[0].voice_library_id == "legacy_voice"
def test_preview_queue_limit_checks_total_count(self):
"""限流预检查按变体总数计:用户 pending + N 超限 → 429"""
from app.api.routes.generation_preview import create_preview_generation_task
from fastapi import HTTPException
repo = MagicMock()
repo.count_pending_by_user.return_value = 3
repo.count_pending_total.return_value = 0
with pytest.raises(HTTPException) as exc:
create_preview_generation_task(
_make_preview_request(preview_count=5),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
assert exc.value.status_code == 429
def test_preview_clone_failure_marks_all_failed(self):
"""克隆变体 plan 失败 → 已创建任务全部标记 failed 并 500"""
from app.api.routes.generation_preview import create_preview_generation_task
from fastapi import HTTPException
tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)]
repo = _repo_mock()
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.side_effect = tasks
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
MockPlanSvc.return_value.clone_plan_for_variant.side_effect = RuntimeError("db down")
with pytest.raises(HTTPException) as exc:
create_preview_generation_task(
_make_preview_request(preview_count=3),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
assert exc.value.status_code == 500
# 所有已创建任务都被标记 failed
assert all(t.status == GenerationTaskStatus.FAILED for t in tasks)
# ════════════════════════════════════════════════════════════════════════════
# 批量正式生成:变体配置注入
# ════════════════════════════════════════════════════════════════════════════
class TestBatchGenerationVariantConfig:
"""POST /tasks count=N 时变体独立配置。"""
def _call_create_tasks(self, request, repo=None):
from app.api.routes.generation_tasks import create_generation_task
repo = repo or MagicMock()
repo.count_pending_by_user.return_value = 0
repo.count_pending_total.return_value = 0
repo.update.return_value = None
# 模板模式:asset_repository.find_by_id 返回 None(无 project 关联,
# 纯模板模式 project_id/library_id 都为空),避免 MagicMock 属性污染
asset_repo = MagicMock()
asset_repo.find_by_id.return_value = None
# db.query().filter()...first() 返回 None:不走兜底关联编辑计划
db = MagicMock()
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
return create_generation_task(
request,
authenticated_user=_make_user(),
generation_task_repository=repo,
project_repository=MagicMock(),
asset_library_repository=MagicMock(),
asset_repository=asset_repo,
db=db,
)
def test_count_3_variant_titles_voices_covers_injected(self):
"""count=3titles/voice_library_ids/cover_urls 按变体注入"""
from app.api.routes import generation_tasks as routes
tasks = [_make_task(task_id=f"gen_{i}") for i in range(3)]
captured = []
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
def _execute(cmd):
captured.append(cmd)
t = tasks[len(captured) - 1]
t.title_config = cmd.title_config
t.voice_library_id = cmd.voice_library_id
t.cover_url = cmd.cover_url
return t
MockUC.return_value.execute.side_effect = _execute
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
req = CreateGenerationTaskRequest(
template_id="tpl_1",
asset_ids=["a1"],
count=3,
title_config={"font": "宋体"},
titles=["成片标题1", "成片标题2", "成片标题3"],
voice_library_ids=["v1", "v2", "v3"],
cover_urls=["http://c1", "http://c2", "http://c3"],
)
resp = self._call_create_tasks(req)
assert resp.total == 3
assert [c.title_config["text"] for c in captured] == ["成片标题1", "成片标题2", "成片标题3"]
assert [c.voice_library_id for c in captured] == ["v1", "v2", "v3"]
assert [c.cover_url for c in captured] == ["http://c1", "http://c2", "http://c3"]
# 样式共用
assert all(c.title_config["font"] == "宋体" for c in captured)
def test_count_1_legacy_fields_unchanged(self):
"""N=1 不传数组:旧字段 voice_library_id/cover_url/title_config 行为不变"""
from app.api.routes import generation_tasks as routes
task = _make_task(task_id="gen_1")
task.is_preview = False
captured = []
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
def _execute(cmd):
captured.append(cmd)
return task
MockUC.return_value.execute.side_effect = _execute
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
req = CreateGenerationTaskRequest(
template_id="tpl_1",
asset_ids=["a1"],
count=1,
voice_library_id="legacy_voice",
cover_url="http://legacy-cover",
title_config={"text": "旧标题", "font": "黑体"},
)
resp = self._call_create_tasks(req)
assert resp.total == 1
assert captured[0].voice_library_id == "legacy_voice"
assert captured[0].cover_url == "http://legacy-cover"
assert captured[0].title_config["text"] == "旧标题"
def test_count_3_shared_single_value_arrays(self):
"""数组长度 1:3 个变体共用同一配音/封面"""
from app.api.routes import generation_tasks as routes
tasks = [_make_task(task_id=f"gen_{i}") for i in range(3)]
captured = []
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
def _execute(cmd):
captured.append(cmd)
return tasks[len(captured) - 1]
MockUC.return_value.execute.side_effect = _execute
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
req = CreateGenerationTaskRequest(
template_id="tpl_1",
asset_ids=["a1"],
count=3,
voice_library_ids=["shared_voice"],
cover_urls=["http://shared"],
)
self._call_create_tasks(req)
assert all(c.voice_library_id == "shared_voice" for c in captured)
assert all(c.cover_url == "http://shared" for c in captured)
class TestVariantValueHelper:
"""_variant_value 取值逻辑。"""
def test_empty_returns_fallback(self):
from app.api.routes.generation_preview import _variant_value
assert _variant_value([], 0, fallback="fb") == "fb"
def test_single_length_shared(self):
from app.api.routes.generation_preview import _variant_value
assert _variant_value(["only"], 5) == "only"
def test_indexed_access(self):
from app.api.routes.generation_preview import _variant_value
assert _variant_value(["a", "b", "c"], 1) == "b"
def test_index_out_of_range_fallback(self):
from app.api.routes.generation_preview import _variant_value
assert _variant_value(["a", "b"], 9, fallback="x") == "x"
+15 -12
View File
@@ -85,17 +85,18 @@ class TestIsBadFingerprint:
assert VideoDeduplicator._is_bad_fingerprint(["abcdef0123456789"]) is False
def test_all_identical_phashes_is_bad(self):
"""多帧但所有 phash 完全相同 → 黑屏/纯色视频。"""
phashes = ["aaaaaaaaaaaaaaaa"] * 5
""">=8 帧且所有 phash 完全相同 → 黑屏/纯色视频#1702:短帧不误杀)"""
phashes = ["aaaaaaaaaaaaaaaa"] * 10
assert VideoDeduplicator._is_bad_fingerprint(phashes) is True
def test_two_identical_phashes_is_bad(self):
"""帧完全相同也视为坏指纹"""
assert VideoDeduplicator._is_bad_fingerprint(["bbbbbbbbbbbbbbbb", "bbbbbbbbbbbbbbbb"]) is True
def test_short_identical_phashes_not_bad(self):
"""<8 帧完全相同不判坏——短视频内容连续时相邻采样帧 phash 天然相同(#1702"""
assert VideoDeduplicator._is_bad_fingerprint(["bbbbbbbbbbbbbbbb"] * 5) is False
assert VideoDeduplicator._is_bad_fingerprint(["bbbbbbbbbbbbbbbb", "bbbbbbbbbbbbbbbb"]) is False
def test_all_very_similar_phashes_is_bad(self):
"""帧 phash 之间的汉明距离都 < 3 → 近似黑屏。"""
phashes = ["0000000000000000", "0000000000000001", "0000000000000002"]
""">=8 帧 phash 之间的汉明距离都 < 3 且高占比 → 近似黑屏。"""
phashes = ["0000000000000000"] * 8 + ["0000000000000001", "0000000000000002"]
assert VideoDeduplicator._is_bad_fingerprint(phashes) is True
def test_diverse_phashes_is_good(self):
@@ -122,7 +123,9 @@ class TestIsBadFingerprint:
"""已知黑屏视频的 phash 特征(全零或均匀分布)。"""
assert VideoDeduplicator._is_bad_fingerprint(["0000000000000000"] * 10) is True
assert VideoDeduplicator._is_bad_fingerprint(["ffffffffffffffff"] * 8) is True
assert VideoDeduplicator._is_bad_fingerprint(["9999999999999966"] * 6) is True
assert VideoDeduplicator._is_bad_fingerprint(["9999999999999966"] * 8) is True
# <8 帧不判坏(#1702 短视频保护)
assert VideoDeduplicator._is_bad_fingerprint(["9999999999999966"] * 5) is False
# ── Helper ──────────────────────────────────────────────────────
@@ -151,13 +154,13 @@ class TestCheckDuplicateBadFingerprint:
deduplicator = VideoDeduplicator()
mock_session = MagicMock()
black_screen = _make_existing_video("vid-black", "md5_black", ["aaaaaaaaaaaaaaaa"] * 5)
black_screen = _make_existing_video("vid-black", "md5_black", ["aaaaaaaaaaaaaaaa"] * 10)
mock_repo = MagicMock()
mock_repo.list_by_user.return_value = [black_screen]
fingerprint = VideoFingerprint(
md5="md5_normal",
keyframe_phashes=["aaaaaaaaaaaaaaaa"] * 5,
keyframe_phashes=["aaaaaaaaaaaaaaaa"] * 10,
color_histograms=[],
duration=10.0,
resolution=(1280, 720),
@@ -206,7 +209,7 @@ class TestCheckDuplicateBadFingerprint:
deduplicator = VideoDeduplicator()
mock_session = MagicMock()
black_screen = _make_existing_video("vid-black", "same_md5", ["aaaaaaaaaaaaaaaa"] * 5)
black_screen = _make_existing_video("vid-black", "same_md5", ["aaaaaaaaaaaaaaaa"] * 10)
mock_repo = MagicMock()
mock_repo.list_by_user.return_value = [black_screen]
@@ -283,7 +286,7 @@ class TestComputeDuplicateRateBadFingerprint:
mock_session = MagicMock()
videos = [
_make_existing_video("vid-b1", "md5_b1", ["aaaaaaaaaaaaaaaa"] * 5),
_make_existing_video("vid-b1", "md5_b1", ["aaaaaaaaaaaaaaaa"] * 10),
_make_existing_video("vid-b2", "md5_b2", ["bbbbbbbbbbbbbbbb"] * 5),
]
mock_repo = MagicMock()
+432
View File
@@ -0,0 +1,432 @@
"""Issue #1702 — 查重率恒为 0% 修复:单测.
覆盖验收要求:
1. 同源不同裁剪的两个视频能检出非 0 相似度(指纹中心裁剪绕开降重 + 阈值校准)
2. 局部片段复用(B 结尾 2s ≈ A 中间 2s)能检出
3. 异源视频不误报(相似度接近 0
4. N=1 现有流程不回归
5. P1 确定性 bug:时长预过滤单位 /1000、直方图归一化、temporal_coverage 量纲、阈值比较统一
6. P0:±1 邻接对齐、短视频自适应连续门槛
7. P20 匹配也要落日志
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.modules.setdefault("cv2", MagicMock())
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "apps" / "worker"))
sys.path.insert(0, str(ROOT / "packages"))
from video_processing.dedup import ( # noqa: E402
PHASH_THRESHOLD,
SEGMENT_MATCH_THRESHOLD,
FingerprintChunk,
VideoDeduplicator,
VideoFingerprint,
find_duplicate_segments,
)
# ── helpers ────────────────────────────────────────────────────
def _h(d: int) -> str:
"""64-bit phash with exactly d bits set vs zero hash."""
bits = ["0"] * 64
for i in range(d):
bits[i] = "1"
return f"{int(''.join(bits), 2):016x}"
def _chunk(phash: str, t0: float, t1: float):
return FingerprintChunk(
start_time_ms=int(t0 * 1000),
end_time_ms=int(t1 * 1000),
phash_binary=phash,
color_histogram=[],
frame_count=1,
)
def _fingerprint(phashes, duration, chunks=None, md5="fp-md5-x"):
return VideoFingerprint(
md5=md5,
keyframe_phashes=list(phashes),
color_histograms=[],
duration=duration,
resolution=(1280, 720),
chunks=chunks or [],
)
def _video(vid, phashes, duration=10.0, project_id="proj1"):
from packages.domain import GeneratedVideo
return GeneratedVideo(
id=vid,
project_id=project_id,
generation_task_id=f"task-{vid}",
name=f"video-{vid}.mp4",
file_url=f"https://example.com/{vid}.mp4",
file_size=1000,
duration=duration,
width=1280,
height=720,
fps=25.0,
video_fingerprint={"md5": f"md5-{vid}", "keyframe_phashes": list(phashes)},
)
def _rate(deduplicator, fp, videos, session=None):
session_magic = MagicMock()
# 分片表无数据 -> 回退 JSON keyframe_phashes
session_magic.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
repo = MockRepo.return_value
repo.list_by_project.return_value = videos
repo.list_by_user.return_value = videos
return deduplicator.compute_duplicate_rate(fp, "proj1", "new-vid", session_magic, scope="project")
def _check(deduplicator, fp, videos, scope="project", **kw):
session_magic = MagicMock()
session_magic.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
repo = MockRepo.return_value
repo.list_by_project.return_value = videos
repo.list_by_user.return_value = videos
return deduplicator.check_duplicate(fp, "proj1", session_magic, scope=scope, **kw)
# ── P0-1/P0-2: 同源不同裁剪(距离 6~10)检出非 0 ──────────────
class TestSameSourceDifferentCrop:
"""同源成片:random_edge_crop 后 pHash 距离 6~10,应检出非 0 相似度。"""
def test_same_source_high_similarity_detected(self):
ddp = VideoDeduplicator()
# 新视频 5 个分片,每个 phash 与已有视频对应分片距离 6(< 阈值)
base = [_h(0) for _ in range(5)]
new = [_h(6) for _ in range(5)]
existing = _video("v-old", base, duration=11.0)
chunks = [_chunk(h, i * 2.2, (i + 1) * 2.2) for i, h in enumerate(new)]
fp = _fingerprint(new, 11.0, chunks=chunks)
result = _rate(ddp, fp, [existing], MagicMock())
assert result["duplicate_rate"] > 0
assert result["visual_similarity"] > 0
def test_same_source_distance_at_threshold_still_detected(self):
"""距离正好等于阈值(<=)也要算匹配——阈值比较统一为 <=。"""
assert PHASH_THRESHOLD <= 12, "阈值应经校准保持在能检出同源裁剪的范围"
ddp = VideoDeduplicator()
base = [_h(0) for _ in range(6)]
new = [_h(PHASH_THRESHOLD) for _ in range(6)]
existing = _video("v-old", base, duration=12.0)
chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)]
fp = _fingerprint(new, 12.0, chunks=chunks)
result = _rate(ddp, fp, [existing], MagicMock())
assert result["duplicate_rate"] > 0
# ── P0-2: 局部片段复用(B 结尾 2s ≈ A 中间 2s) ────────────────
class TestPartialReuse:
def test_partial_reuse_tail_overlap_detected(self):
"""新视频 6 片,最后 2 片命中已有视频中间 2 片(距离 4),其余不匹配。
旧逻辑 frame_match_rate=2/6≈0.33<0.3 硬跳过边界)+ MIN_CONSECUTIVE=5
导致完全检不出;新逻辑 coverage 为主指标 + 自适应门槛应检出。
"""
ddp = VideoDeduplicator()
# 已有 8 片:索引 3、4 是被复用的镜头
old = [_h(20 + i) for i in range(8)]
# 新视频 6 片:最后 2 片对应 old[3], old[4],距离 4;其余距离 30
new = [_h(50 + i) for i in range(4)] + [_h(4)] * 2
# 让 new[4] 与 old[3] 距离 4、new[5] 与 old[4] 距离 4(构造近似)
new[4] = f"{int('1' * 4 + '0' * 60, 2):016x}"
new[5] = f"{int('1' * 4 + '0' * 60, 2):016x}"
old[3] = _h(0)
old[4] = _h(0)
existing = _video("v-old", old, duration=16.0)
chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)]
fp = _fingerprint(new, 12.0, chunks=chunks)
result = _rate(ddp, fp, [existing], MagicMock())
# 局部复用:duplicate_rate 必须非 0
assert result["duplicate_rate"] > 0
def test_short_video_adaptive_consecutive_threshold(self):
"""11s/5 片短视频:MIN_CONSECUTIVE 自适应 min(5, max(2, 5//2))=2
2 片连续命中即报片段(旧值 5 让短视频永远无法报片段)。"""
q = [
FingerprintChunk(0, 2000, "f" * 16, []),
FingerprintChunk(2000, 4000, "0" * 16, []),
FingerprintChunk(4000, 6000, f"{int('11110000', 2):016x}", []),
]
t = [
FingerprintChunk(0, 2000, "f" * 16, []),
FingerprintChunk(2000, 4000, "0" * 16, []),
FingerprintChunk(4000, 6000, "e" * 16, []),
]
# 3 片视频自适应门槛 = min(5, max(2, 3//2)) = 2
segs = find_duplicate_segments(q, t)
assert len(segs) >= 1
# ── P0-3: ±1 邻接窗口对齐 ─────────────────────────────────────
class TestNeighborAlignment:
def test_neighbor_window_absorbs_boundary_jitter(self):
"""切点错位导致目标索引偏移 ±1 时,连续匹配不应被中断。"""
q = [FingerprintChunk(i * 1000, (i + 1) * 1000, f"{i:016x}", []) for i in range(4)]
# 目标:前 3 片与 q 相同,但第 3 片最佳匹配偏移 +1(t[4]),t[3] 是无关内容
t_hashes = [f"{i:016x}" for i in range(3)] + ["f" * 16, f"{3:016x}"]
t = [FingerprintChunk(i * 1000, (i + 1) * 1000, h, []) for i, h in enumerate(t_hashes)]
segs = find_duplicate_segments(q, t)
# q[0],q[1] 精确匹配 t[0],t[1]q[2]->t[2]q[3]->t[4](步进 2,窗口 ±1 内)
assert len(segs) >= 1
assert segs[0].query_end_ms >= 3000
# ── P0-5 / 验收:异源不误报 ───────────────────────────────────
class TestDifferentSourceNoFalsePositive:
def test_unrelated_videos_near_zero(self):
ddp = VideoDeduplicator()
# 异源:所有分片距离 >= 20
old = [_h(40 + i * 3 % 20) for i in range(6)]
new = [_h(0 + i) for i in range(6)]
existing = _video("v-old", old, duration=12.0)
chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)]
fp = _fingerprint(new, 12.0, chunks=chunks)
result = _rate(ddp, fp, [existing], MagicMock())
assert result["duplicate_rate"] == 0
assert result["visual_similarity"] < 0.7
assert result["match_count"] == 0
def test_check_duplicate_returns_none_for_unrelated(self):
ddp = VideoDeduplicator()
old = [_h(40 + i) for i in range(6)]
new = [_h(i) for i in range(6)]
existing = _video("v-old", old, duration=12.0)
fp = _fingerprint(new, 12.0)
result = _check(ddp, fp, [existing])
assert result is None
# ── N=1 不回归 ────────────────────────────────────────────────
class TestSingleChunkNoRegression:
def test_single_chunk_identical_detected(self):
ddp = VideoDeduplicator()
h = _h(2)
existing = _video("v-old", [h], duration=3.0)
chunks = [_chunk(h, 0, 3000)]
fp = _fingerprint([h], 3.0, chunks=chunks)
result = _rate(ddp, fp, [existing], MagicMock())
assert result["duplicate_rate"] > 0
def test_single_chunk_md5_exact_match(self):
ddp = VideoDeduplicator()
existing = _video("v-old", [_h(0)], duration=3.0)
existing.video_fingerprint["md5"] = "same"
fp = _fingerprint([_h(0)], 3.0, md5="same")
result = _check(ddp, fp, [existing])
assert result is not None
assert result["reason"] == "exact_md5_match"
# ── P1-6: 时长预过滤单位 bug ──────────────────────────────────
class TestDurationPrefilterUnit:
def test_user_scope_skips_duration_prefilter(self):
"""Issue #1702: scope=user 跨项目查重不做 ±15% 时长预过滤。
旧逻辑 duration/1000 单位 bug 先修成秒,但 ±15% 窗口与局部片段复用
根本矛盾——复用片段的两个视频时长必然不同(证据视频 20s vs 11s 差 42%),
窗口内找不到对方导致 is_duplicate 恒 False。最终口径:scope=user 全量
遍历同用户视频(与 compute_duplicate_rate 一致),不传 duration_min/max。
"""
ddp = VideoDeduplicator()
fp = _fingerprint([_h(0)], 13.5)
session_magic = MagicMock()
session_magic.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
repo = MockRepo.return_value
repo.list_by_user.return_value = []
ddp.check_duplicate(fp, "proj1", session_magic, scope="user", user_id="u1", duration_sec=fp.duration)
args, kwargs = repo.list_by_user.call_args
# 全量查询:不带任何时长过滤参数(局部复用必须跨时长比较)
assert "duration_min" not in kwargs
assert "duration_max" not in kwargs
assert args == ("u1",) or args == ()
# ── P1-7: 颜色直方图归一化 ────────────────────────────────────
class TestHistogramNormalization:
def test_bhattacharyya_coefficient_in_unit_range(self):
"""Bhattacharyya 系数必须在 [0,1](旧 L2 + 3 通道拼接算出 ~14.9)。"""
# 3 通道拼接、每通道概率分布(Σ=1)
hist_a = [0.5, 0.5] + [0.0] * 94 + [0.5, 0.5] + [0.0] * 94 + [0.5, 0.5] + [0.0] * 94
# 长度裁剪到 96(3 通道 × 32 bins
hist_a = ([0.5, 0.5] + [0.0] * 30) * 3
hist_b = ([0.5, 0.5] + [0.0] * 30) * 3
coeff = VideoDeduplicator._bhattacharyya_coefficient(hist_a, hist_b)
assert 0.0 <= coeff <= 1.0
assert coeff > 0.99 # 完全相同 -> 1.0
def test_bhattacharyya_disjoint_hist_low(self):
hist_a = ([1.0] + [0.0] * 31) * 3
hist_b = ([0.0] * 31 + [1.0]) * 3
coeff = VideoDeduplicator._bhattacharyya_coefficient(hist_a, hist_b)
assert coeff < 0.05
# ── P1-8: temporal_coverage 量纲 ──────────────────────────────
class TestTemporalCoverageUnits:
def test_coverage_uses_milliseconds(self):
"""命中片段 6s / 视频 12s -> coverage=0.5;旧 bug 把 duration(秒)当毫秒,
covered_ms(6000)/duration(12) = 500 -> min(1.0)=1.0 误判 100% 覆盖。"""
ddp = VideoDeduplicator()
old = [_h(0) for _ in range(6)]
new = [_h(0) for _ in range(3)] + [_h(30) for _ in range(3)]
existing = _video("v-old", old, duration=12.0)
# 新视频 12s,前 6s(3 片)与 old 相同
chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)]
fp = _fingerprint(new, 12.0, chunks=chunks)
result = _rate(ddp, fp, [existing], MagicMock())
# coverage 应约 0.53 片 × 2s = 6s / 12s),duplicate_rate ≈ (0.5*0.4 + 0.5*0.6)*100 = 50
assert 30 < result["duplicate_rate"] < 70
# ── P1-9: 阈值比较统一 ────────────────────────────────────────
class TestThresholdConsistency:
def test_frame_and_segment_thresholds_same_source(self):
assert SEGMENT_MATCH_THRESHOLD == PHASH_THRESHOLD
assert VideoDeduplicator.PHASH_THRESHOLD == PHASH_THRESHOLD
# ── P2: 0 匹配也要有日志痕迹 ──────────────────────────────────
class TestZeroMatchLogging:
def test_no_match_emits_info_log(self, caplog):
ddp = VideoDeduplicator()
old = [_h(40 + i) for i in range(5)]
existing = _video("v-old", old, duration=10.0)
fp = _fingerprint([_h(i) for i in range(5)], 10.0)
with caplog.at_level(logging.INFO, logger="video_processing.dedup"):
result = _check(ddp, fp, [existing])
assert result is None
assert any("no match" in r.message for r in caplog.records)
# ── recompute 任务下载路径(#1702 连带修复:旧硬编码 key 404) ─────
class TestRecomputeDownloadPath:
"""recompute-dedup 走 check_duplicate_task,需要从 OSS 重新下载成片。
旧代码硬编码 projects/{pid}/generated/{vid}/{vid}.mp4(从不存在),
真实 key 在 file_urlgenerated/projects/{pid}/tasks/{tid}/rendered_*.mp4。
"""
def test_task_downloads_from_file_url(self):
import inspect
import video_processing.dedup as dedup_mod
source = inspect.getsource(dedup_mod.check_duplicate_task)
# 下载 key 必须来自 video.file_url
assert 'getattr(video, "file_url"' in source or "video.file_url" in source
# 旧的硬编码 key 只能作为回退存在,不能是主路径
assert "falling back to legacy key" in source
# download_file 接收的是派生 key 而非硬编码 f-string
assert "storage_service.download_file(download_key" in source
assert '/generated/{generated_video_id}/{generated_video_id}.mp4"' not in source.replace(
'download_key = f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4"',
"",
)
# ── check_duplicate 排除自身(#1702 连带修复:recompute 自匹配) ─────
class TestCheckDuplicateExcludesSelf:
def test_exclude_video_id_skips_self_match(self):
"""recompute 时当前视频已在候选列表:自匹配距离 0 分会让 duplicate_of
指向自己。exclude_video_id 必须跳过自身,返回真实的其他匹配或 None。
"""
ddp = VideoDeduplicator()
h = _h(0)
# 候选列表里同时放「自己」(完全相同)和一个异源视频
self_video = _video("v-self", [h], duration=10.0)
other_video = _video("v-other", [_h(40 + i) for i in range(3)], duration=10.0)
fp = _fingerprint([h], 10.0)
session = MagicMock()
session.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
# 不传 exclude → 自匹配命中(错误行为复现)
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
MockRepo.return_value.list_by_project.return_value = [self_video, other_video]
result = ddp.check_duplicate(fp, "proj1", session)
assert result is not None and result["duplicate_of"] == "v-self"
# 传 exclude_video_id → 跳过自己,异源不匹配 → None
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
MockRepo.return_value.list_by_project.return_value = [self_video, other_video]
result = ddp.check_duplicate(fp, "proj1", session, exclude_video_id="v-self")
assert result is None
# 排除自己后,真实同源其他视频仍能检出
real_dup = _video("v-real", [h], duration=10.0)
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
MockRepo.return_value.list_by_project.return_value = [self_video, real_dup]
result = ddp.check_duplicate(fp, "proj1", session, exclude_video_id="v-self")
assert result is not None and result["duplicate_of"] == "v-real"
+6 -6
View File
@@ -358,11 +358,11 @@ class TestVideoDeduplicatorCheckDuplicate:
finally:
self._restore_repo(mod, orig)
def test_first_match_returned(self, deduplicator, mock_session):
"""返回第一个通过阈值的匹配(非最优匹配)。"""
# vid-1: 距离=2 bits0x03 XOR 0x01 = 0x02 → 1 bit),通过阈值
def test_highest_score_match_returned(self, deduplicator, mock_session):
"""Issue #1702: 遍历所有候选取融合分最高者(旧逻辑首个过阈即返回)。"""
# vid-1: 距离=1 bit0x03 XOR 0x01 = 0x02 → 1 bit),通过阈值
vid1 = self._make_existing_video("vid-1", "md5_1", phashes=["0000000000000003"])
# vid-2: 距离=0 bits(完全匹配)
# vid-2: 距离=0 bits(完全匹配),融合分更高
vid2 = self._make_existing_video("vid-2", "md5_2", phashes=["0000000000000001"])
mock_repo = MagicMock()
@@ -380,8 +380,8 @@ class TestVideoDeduplicatorCheckDuplicate:
try:
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
assert result is not None
# 返回第一个通过阈值的匹配(vid-1 距离=1 < 10
assert result["duplicate_of"] == "vid-1"
# 两个候选都过阈,返回融合分最高的 vid-2(距离 0 < 1
assert result["duplicate_of"] == "vid-2"
finally:
self._restore_repo(mod, orig)
+10 -7
View File
@@ -185,11 +185,14 @@ class TestBhattacharyyaCoefficient:
"""_bhattacharyya_coefficient Bhattacharyya 系数测试."""
def test_identical_histograms(self):
"""完全相同的直方图系数为1.0."""
hist = [0.5, 0.5, 0.0, 0.3]
"""完全相同的直方图系数为1.0(#1702:按 Σ 归一,概率分布语义)。"""
hist = [0.5, 0.5, 0.0, 0.0] # Σ=1 的概率分布
bc = VideoDeduplicator._bhattacharyya_coefficient(hist, hist)
# Σ √(a[i]*a[i]) = Σ a[i] = 1.0 (normalized)
assert bc == pytest.approx(sum(h for h in hist))
assert bc == pytest.approx(1.0)
# 非归一化输入也归一到 1.0(三通道拼接 Σ=3 的等价情形)
hist3 = [0.5, 0.5, 0.0, 0.3]
bc3 = VideoDeduplicator._bhattacharyya_coefficient(hist3, hist3)
assert bc3 == pytest.approx(1.0)
def test_zero_histograms(self):
"""全零直方图系数为0."""
@@ -202,10 +205,10 @@ class TestBhattacharyyaCoefficient:
assert bc == pytest.approx(0.0)
def test_different_lengths(self):
"""不同长度直方图取最小长度对齐."""
"""不同长度直方图取最小长度对齐,并按各自总量归一(#1702 概率分布语义)。"""
# 对齐到前 2 维:coeff = 2norm = √(Σa·Σb) = √(2·2) = 2 → 1.0
bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 1.0, 0.0, 0.0], [1.0, 1.0])
# 对齐到前2维: √(1*1) + √(1*1) = 2.0
assert bc == pytest.approx(2.0)
assert bc == pytest.approx(1.0)
def test_known_value(self):
"""已知值验证."""
+7 -4
View File
@@ -484,8 +484,10 @@ class TestBackwardCompatibility:
chunks_b = [{"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": 0, "end_time_ms": 5000}]
segments = find_duplicate_segments(chunks_a, chunks_b)
# 1 帧 < min_consecutive=5,不会报重复
assert segments == []
# Issue #1702: 自适应门槛 min(5, max(2, 1//2))=21 帧不成段;
# N=1 的检出由 _evaluate_candidate 匹配帧回退兜底(见 test_dedup_1702)。
# 这里只要求不崩溃。
assert isinstance(segments, list)
# ── TestConstants ───────────────────────────────────────────────
@@ -495,8 +497,9 @@ class TestConstants:
"""常量值验证 — 使用已在模块顶部导入的常量,避免重新 import."""
def test_segment_match_threshold(self):
# 从已导入的 find_duplicate_segments 默认参数间接验证
assert SEGMENT_MATCH_THRESHOLD == 8
# Issue #1702: pHash 阈值经 staging 真实同源/异源指纹回归校准
# (同源密集采样 min=8、异源 min=24),统一为模块常量 PHASH_THRESHOLD=12。
assert SEGMENT_MATCH_THRESHOLD == 12
def test_min_consecutive_matches(self):
assert MIN_CONSECUTIVE_MATCHES == 5
+40 -8
View File
@@ -132,9 +132,14 @@ class TestCheckDuplicateScopeUser:
class TestDurationPrefilter:
"""test_duration_prefilter:时长 ±15% 过滤."""
"""Issue #1702: scope=user 跨项目查重不做时长预过滤
def test_duration_prefilter_passes_correct_range(self):
局部片段复用的两个视频时长必然不同(证据视频 20s vs 11s,差 42%),
旧的 ±15% 窗口会让同源视频互相不可见 → is_duplicate 恒 False。
全量遍历同用户视频,异源视频由 fusion/temporal_coverage 阈值天然过滤。
"""
def test_user_scope_no_duration_filter(self):
from video_processing.dedup import VideoDeduplicator
deduplicator = VideoDeduplicator()
@@ -153,12 +158,13 @@ class TestDurationPrefilter:
duration_sec=30.0,
)
# Should pass duration_min=25.5, duration_max=34.5 (30 ± 15%)
# scope=user 全量遍历:位置参数只传 user_id,kwargs 不含时长过滤
call_args = mock_repo.list_by_user.call_args
assert call_args[1]["duration_min"] == pytest.approx(25.5, abs=0.1)
assert call_args[1]["duration_max"] == pytest.approx(34.5, abs=0.1)
assert call_args[0] == ("user1",)
assert "duration_min" not in call_args[1]
assert "duration_max" not in call_args[1]
def test_no_duration_prefilter_when_zero(self):
def test_user_scope_no_duration_filter_when_zero(self):
from video_processing.dedup import VideoDeduplicator
deduplicator = VideoDeduplicator()
@@ -178,8 +184,34 @@ class TestDurationPrefilter:
)
call_args = mock_repo.list_by_user.call_args
assert call_args[1]["duration_min"] == 0
assert call_args[1]["duration_max"] == 0
assert "duration_min" not in call_args[1]
assert "duration_max" not in call_args[1]
def test_project_scope_also_no_duration_filter(self):
"""scope=project 走 list_by_project,本来就不做时长过滤。"""
from video_processing.dedup import VideoDeduplicator
deduplicator = VideoDeduplicator()
fingerprint = _make_fingerprint(duration_ms=30000)
session = MagicMock()
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
mock_repo = MockRepo.return_value
mock_repo.list_by_project.return_value = []
deduplicator.check_duplicate(
fingerprint,
"proj1",
session,
scope="project",
user_id="user1",
duration_sec=30.0,
)
mock_repo.list_by_project.assert_called_once()
call_args = mock_repo.list_by_project.call_args
assert call_args[0] == ("proj1",)
assert "duration_min" not in call_args[1]
assert "duration_max" not in call_args[1]
class TestComputeDuplicateRateFormula:
+26 -16
View File
@@ -3,7 +3,7 @@
覆盖:
- 分片策略:60秒视频 → 30片,120秒视频 → 24片
- VideoFingerprint.to_chunk_models() 输出正确
- _save_fingerprint_chunks 幂等性(已有数据跳过
- _save_fingerprint_chunks 替换语义(Issue #1702:重算时先删旧分片再写入
- to_dict() 向后兼容
"""
@@ -169,11 +169,15 @@ class TestVideoFingerprintToChunkModels:
assert models == []
class TestSaveFingerprintChunksIdempotent:
"""测试 _save_fingerprint_chunks 幂等性。"""
class TestSaveFingerprintChunksReplace:
"""测试 _save_fingerprint_chunks 替换语义(Issue #1702)。
def test_save_skips_existing(self):
"""已有分片数据时跳过写入。"""
重算查重时指纹算法已升级(中心裁剪 + 新采样/阈值),旧分片必须先删除
再写入新分片,否则 recompute-dedup 永远读到旧指纹、修复对存量视频不生效。
"""
def test_save_replaces_existing(self):
"""已有分片数据时:先删除旧分片,再写入新分片。"""
fp = VideoFingerprint(
md5="abc",
keyframe_phashes=["a1b2"],
@@ -186,16 +190,22 @@ class TestSaveFingerprintChunksIdempotent:
)
session = MagicMock()
# Mock: 已有 1 条分片数据
session.query.return_value.filter.return_value.count.return_value = 1
# Mock: 删除旧分片返回 3(旧算法留下的 3 条分片
session.query.return_value.filter.return_value.delete.return_value = 3
_save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session)
# bulk_save_objects 不应被调用
session.bulk_save_objects.assert_not_called()
# 必须先执行删除
session.query.return_value.filter.return_value.delete.assert_called_once()
# 新分片必须写入
session.bulk_save_objects.assert_called_once()
saved_models = session.bulk_save_objects.call_args[0][0]
assert len(saved_models) == 1
assert saved_models[0].video_id == "v1"
assert saved_models[0].phash_binary == "a1b2"
def test_save_writes_new(self):
"""无分片数据时写入。"""
"""分片时直接写入。"""
fp = VideoFingerprint(
md5="abc",
keyframe_phashes=["a1b2"],
@@ -208,12 +218,12 @@ class TestSaveFingerprintChunksIdempotent:
)
session = MagicMock()
# Mock: 无分片数据
session.query.return_value.filter.return_value.count.return_value = 0
# Mock: 无分片
session.query.return_value.filter.return_value.delete.return_value = 0
_save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session)
# bulk_save_objects 应被调用一次
session.query.return_value.filter.return_value.delete.assert_called_once()
session.bulk_save_objects.assert_called_once()
saved_models = session.bulk_save_objects.call_args[0][0]
assert len(saved_models) == 1
@@ -221,7 +231,7 @@ class TestSaveFingerprintChunksIdempotent:
assert saved_models[0].phash_binary == "a1b2"
def test_save_skips_no_chunks(self):
"""指纹无 chunks 时跳过。"""
"""指纹无 chunks 时跳过(不删不写)"""
fp = VideoFingerprint(
md5="abc",
keyframe_phashes=[],
@@ -232,11 +242,11 @@ class TestSaveFingerprintChunksIdempotent:
)
session = MagicMock()
session.query.return_value.filter.return_value.count.return_value = 0
_save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session)
# bulk_save_objects 不应被调用
# 无 chunks:不查询、不删除、不写入
session.query.assert_not_called()
session.bulk_save_objects.assert_not_called()
+36 -11
View File
@@ -725,8 +725,12 @@ class TestCreatePreviewRoute:
generation_task_repository=repo,
db=MagicMock(),
)
assert resp.task_id == "preview_task_001"
assert resp.status == "pending"
# 批量响应:N=1 时 items 长度为 1
assert resp.total == 1
assert len(resp.items) == 1
assert resp.items[0].task_id == "preview_task_001"
assert resp.items[0].status == "pending"
assert resp.items[0].variant_index == 0
def test_user_pending_limit_exceeded(self):
"""用户待处理任务超限 → 429"""
@@ -807,7 +811,13 @@ class TestCreatePreviewRoute:
repo.count_pending_total.return_value = 0
task = _make_task()
from fastapi import HTTPException
# 模拟 mark_failed 真实更新任务状态(_mark_task_failed 内部调用)
def _set_failed(error_message="", **_kwargs):
task.status = GenerationTaskStatus.FAILED
task.error_message = error_message
task.mark_failed.side_effect = _set_failed
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
@@ -815,14 +825,15 @@ class TestCreatePreviewRoute:
"app.api.routes.generation_preview.safe_enqueue_generation_task",
return_value=False,
):
with pytest.raises(HTTPException) as exc_info:
create_preview_generation_task(
self._make_request(),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
assert exc_info.value.status_code == 500
resp = create_preview_generation_task(
self._make_request(),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
# 入队失败:任务被标记 failed(mark_failed 设置错误信息),响应正常返回
assert resp.total == 1
assert resp.items[0].status == "failed"
def test_enqueue_raises_user_limit(self):
"""safe_enqueue 抛出 UserPendingLimitExceeded → 429"""
@@ -833,6 +844,12 @@ class TestCreatePreviewRoute:
task = _make_task()
from fastapi import HTTPException
def _set_failed_limit(error_message="", **_kwargs):
task.status = GenerationTaskStatus.FAILED
task.error_message = error_message or "待处理任务超限"
task.mark_failed.side_effect = _set_failed_limit
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
with patch(
@@ -846,6 +863,7 @@ class TestCreatePreviewRoute:
generation_task_repository=repo,
db=MagicMock(),
)
# 全部变体入队失败且错误消息含"待处理任务" → 429
assert exc_info.value.status_code == 429
def test_enqueue_raises_global_queue_full(self):
@@ -857,6 +875,12 @@ class TestCreatePreviewRoute:
task = _make_task()
from fastapi import HTTPException
def _set_failed_queue(error_message="", **_kwargs):
task.status = GenerationTaskStatus.FAILED
task.error_message = error_message or "系统队列已满"
task.mark_failed.side_effect = _set_failed_queue
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.return_value = task
with patch(
@@ -870,6 +894,7 @@ class TestCreatePreviewRoute:
generation_task_repository=repo,
db=MagicMock(),
)
# 全部变体入队失败且错误消息含"队列" → 503
assert exc_info.value.status_code == 503
@@ -102,6 +102,7 @@ from video_processing.dedup import ( # noqa: E402
DUPLICATE_THRESHOLD,
HISTOGRAM_WEIGHT,
MATCH_RATIO_THRESHOLD,
PHASH_THRESHOLD,
PHASH_WEIGHT,
VideoDeduplicator,
)
@@ -128,11 +129,15 @@ _ZERO_HIST = [0.0] * 96 # 全黑视频的全零直方图(有效数据)
class TestThresholdCalibration:
"""pHash 阈值由 10 收紧到 8Issue #1658)。"""
"""pHash 阈值校准(Issue #1658 收紧到 8Issue #1702 经真实指纹分布重校准为 12)。
def test_phash_threshold_is_8(self):
"""PHASH_THRESHOLD 必须为 8(旧值 10 会放过 8~9 汉明距离的不同视频)。"""
assert VideoDeduplicator.PHASH_THRESHOLD == 8
#1702 staging 离线实验:同帧两次 2-5% 随机裁剪距离 4~10;同源成片(密集 1s
采样)最小距离 8、<=12 命中 10/31;异源成片最小距离 24。8 会漏检同源裁剪,
12 检出同源且与异源分布(>=24)间隔充足。
"""
def test_phash_threshold_is_calibrated(self):
assert VideoDeduplicator.PHASH_THRESHOLD == PHASH_THRESHOLD == 12
def test_match_ratio_threshold_constant(self):
assert MATCH_RATIO_THRESHOLD == 0.7
@@ -144,22 +149,21 @@ class TestThresholdCalibration:
assert PHASH_WEIGHT == 0.7
assert HISTOGRAM_WEIGHT == 0.3
def test_threshold_tightening_excludes_distance_8_and_9(self):
"""距离 8、9 的帧:旧阈值 10 下算匹配,新阈值 8 下不算匹配
def test_threshold_matching_semantics(self):
"""阈值比较统一为 <=(帧匹配与片段匹配同一口径)
场景:5 个关键帧距离为 [7, 7, 7, 9, 9]。
- 旧阈值 105 帧全部 < 10 → match_ratio = 1.0(误放过)
- 新阈值 8:仅 3 帧 < 8 → match_ratio = 0.6 < 0.7(正确跳过
场景:5 个关键帧距离为 [10, 12, 12, 24, 26]。
- <=12#1702 校准阈值):3 帧匹配 → 0.6 < 0.7 被帧比例门槛拦截异源
- 距离 12 的同源裁剪帧应算匹配(< 与 <= 口径统一
"""
distances = [7, 7, 7, 9, 9]
distances = [10, 12, 12, 24, 26]
matched = sum(1 for d in distances if d <= VideoDeduplicator.PHASH_THRESHOLD)
assert matched == 3
assert matched / len(distances) == 0.6
assert matched / len(distances) < MATCH_RATIO_THRESHOLD
matched_old = sum(1 for d in distances if d < 10)
assert matched_old == 5 # 旧行为:全匹配 → 误判风险
matched_new = sum(1 for d in distances if d < VideoDeduplicator.PHASH_THRESHOLD)
assert matched_new == 3
assert matched_new / len(distances) == 0.6
assert matched_new / len(distances) < MATCH_RATIO_THRESHOLD # 被帧比例门槛拦截
# 异源典型距离(>=24)绝不匹配
assert not any(d <= VideoDeduplicator.PHASH_THRESHOLD for d in (24, 26, 30))
# ── TestComputeFusionScore:统一融合得分方法 ────────────────────