Compare commits

...

10 Commits

Author SHA1 Message Date
xiaoxia 500203db5d fix(viral-script): tone全链路暴露+ShotScript voiceover字段+删_safe_json_loads死代码(#2295追加) (#2296)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / 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 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m10s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m19s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m2s
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 18s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m44s
CI/CD Pipeline / Validate - Style (push) Successful in 4m52s
CI/CD Pipeline / Integration Tests (push) Failing after 5m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
AI Code Review / AI Code Review (pull_request) Successful in 7m11s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m46s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Validate - Security (push) Successful in 11m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 6m9s
CI/CD Pipeline / Unit Tests (push) Successful in 12m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / 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-10-14 18:42:45 +08:00
CI Bot 2a9089edb0 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / 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 / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 10s
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 15s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - 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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m14s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m5s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / CI Gate (pull_request) Successful in 9s
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 18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m36s
CI/CD Pipeline / Integration Tests (push) Successful in 4m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m27s
CI/CD Pipeline / Validate - Style (push) Successful in 5m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m44s
AI Code Review / AI Code Review (pull_request) Successful in 7m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m27s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 8m37s
CI/CD Pipeline / Validate - Security (push) Successful in 14m12s
CI/CD Pipeline / Unit Tests (push) Successful in 15m2s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-10-14 10:22:44 +00:00
xiaoxia 6a3fbba813 feat(viral-script): 双格式parser+voiceover字段修复+全面审计 (#2295)
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 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 7s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 10s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 57s
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 / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m11s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 28s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 1m41s
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 / PR Build Worker Image (pull_request) Successful in 3m52s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m10s
CI/CD Pipeline / Integration Tests (push) Successful in 7m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m11s
AI Code Review / AI Code Review (pull_request) Successful in 6m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m39s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Failing after 9m45s
2026-10-14 18:12:46 +08:00
xiaoxia 5e5a9b6cda fix(viral-script): v4编导分镜四叠bug(max_chars/超时强制/口播截断/兜底去重/few-shot) (#2294)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m5s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m28s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m42s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m11s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 27s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m52s
CI/CD Pipeline / Integration Tests (push) Successful in 4m26s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 5m21s
CI/CD Pipeline / Validate - Style (push) Successful in 5m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m24s
AI Code Review / AI Code Review (pull_request) Successful in 7m12s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Validate - Security (push) Successful in 10m30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 7m1s
CI/CD Pipeline / Unit Tests (push) Successful in 12m58s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-14 13:49:46 +08:00
xiaoxia ec2e2249fd fix(viral-video): prompt example_output 作为 few-shot 注入 LLM messages,修复v4编导级分镜提示词不生效 (#2293)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 48s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m29s
CI/CD Pipeline / Integration Tests (push) Successful in 3m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 4m53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m24s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m4s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 3m27s
CI/CD Pipeline / Validate - Security (push) Successful in 9m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m53s
CI/CD Pipeline / Unit Tests (push) Successful in 15m47s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m21s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 11m0s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-14 13:09:04 +08:00
xiaoxia cfc584ad11 fix(ditto-emotion): GPU表情公式配套——全兜底置空+强度保底0.35+对齐固化 (#2292)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / 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) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 52s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 57s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m27s
CI/CD Pipeline / Integration Tests (push) Successful in 3m27s
CI/CD Pipeline / Validate - Style (push) Successful in 4m17s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m54s
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 17s
AI Code Review / AI Code Review (pull_request) Successful in 6m55s
CI/CD Pipeline / Validate - Security (push) Successful in 7m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m57s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m25s
CI/CD Pipeline / Unit Tests (push) Successful in 12m3s
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
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m17s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-14 07:26:10 +08:00
xiaoxia afc89142c1 fix(ai-avatar): finalize 接口超时延长到120s (#2291)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / 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 / Frontend Unit Tests (push) Successful in 3m5s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m1s
CI/CD Pipeline / Validate - Style (push) Successful in 6m42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m51s
AI Code Review / AI Code Review (pull_request) Successful in 6m51s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m22s
CI/CD Pipeline / Integration Tests (push) Successful in 6m39s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m40s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m13s
CI/CD Pipeline / Validate - Security (push) Successful in 12m47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m45s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m53s
CI/CD Pipeline / Unit Tests (push) Successful in 14m58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-10-14 06:29:41 +08:00
xiaoxia 6ee4d93d03 test(coverage): ai-avatar 页面组件加入覆盖率排除,修复 CI 门禁 (#2290)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m30s
AI Code Review / AI Code Review (pull_request) Successful in 6m39s
test(coverage): ai-avatar 页面组件加入覆盖率排除,修复 CI 门禁
2026-10-13 23:29:25 +08:00
xiaoxia 5f0533e937 feat(ai-avatar): finalize 接口支持 cover_url/video_title 可选字段 + 兜底自动抽帧 (#2288)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / 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 / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m12s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m56s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m38s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 6m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m46s
CI/CD Pipeline / Validate - Style (push) Successful in 7m41s
AI Code Review / AI Code Review (pull_request) Successful in 7m31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m36s
CI/CD Pipeline / Unit Tests (push) Successful in 15m16s
CI/CD Pipeline / Validate - Security (push) Successful in 44m27s
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 / Staging E2E Tests (push) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m2s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
10 个 finalize 路由单测 + 修复 blob URL 清空后未回退 job.output_cover_url 的优先级 bug
2026-10-13 22:42:59 +08:00
xiaoxia 01fa5dabc4 fix(ai-avatar): 修复选封面点完成后视频封面丢失/未入库 (#2289)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m21s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m29s
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 17s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m54s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m44s
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
AI Code Review / AI Code Review (pull_request) Successful in 7m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 8m57s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 8m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 7m57s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Integration Tests (push) Successful in 37m2s
CI/CD Pipeline / Validate - Style (push) Successful in 37m32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 39m49s
CI/CD Pipeline / Unit Tests (push) Successful in 46m40s
CI/CD Pipeline / Validate - Security (push) Successful in 1h1m59s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(ai-avatar): 修复选封面点完成后视频封面丢失/未入库

根因:finalize 请求体为空,cover_config 只在 submitRender 阶段传空对象,本地上传封面未走 OSS 直传导致后端拿 blob URL 无效。

前端改动:
- finalizeRenderJob 支持传 {cover_url}
- AiAvatarCoverConfig 新增 upload_file 保存本地 File
- 本地上传封面先走 uploadAssetDirect OSS 直传,finalize 带 cover_url
- smart-cover 路径 finalize 不传(后端 /smart-cover 已写入)
- 未选封面时 finalize 不传 body,后端兜底抽帧
- 移除必须选封面的阻塞校验
2026-10-13 21:19:17 +08:00
27 changed files with 1738 additions and 113 deletions
@@ -0,0 +1,23 @@
"""add tone column to viral_video_jobs
Revision ID: 110
Revises: 109_add_viral_video_param_guides
Create Date: 2026-10-14
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "110"
down_revision = "109_add_viral_video_param_guides"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS tone VARCHAR(50) NOT NULL DEFAULT ''")
def downgrade() -> None:
op.execute("ALTER TABLE viral_video_jobs DROP COLUMN IF EXISTS tone")
+58 -4
View File
@@ -18,6 +18,7 @@ from app.dependencies import get_db_session
from app.schemas.ai_avatar_render import (
AiAvatarRenderJobResponse,
CreateAiAvatarRenderRequest,
FinalizeRenderRequest,
FinalizeRenderResponse,
SmartCoverResponse,
)
@@ -26,7 +27,7 @@ from app.services.ai_avatar_render_service import (
AiAvatarRenderError,
AiAvatarRenderService,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@@ -267,13 +268,16 @@ def generate_render_smart_cover(
@router.post("/{job_id}/finalize", response_model=FinalizeRenderResponse)
def finalize_render_job(
job_id: str,
body: FinalizeRenderRequest = Body(default=FinalizeRenderRequest()),
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""用户完成封面选择后,将视频正式保存到成片库.
- 必须等渲染任务 completed 后才可调用
- 如果已通过 smart-cover/custom-cover 设置了封面,会自动带上
- body.cover_url:用户选定的封面公网 URL(本地上传需前端先走 OSS 直传拿公网 URL,blob:/data: URL 无效);
为空时优先使用 smart-cover 已写入的 output_cover_url,仍为空则兜底自动抽一帧
- body.video_title:成片库展示标题;为空用默认 AI数字人_{job_id[:8]}
- 返回成片库视频ID
- 幂等:已 finalize 的任务重复调用会返回 existing 记录
"""
@@ -285,6 +289,9 @@ def finalize_render_job(
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "completed":
raise HTTPException(status_code=400, detail="请先完成视频生成")
video_url = (job.output_video_url or "").strip()
if not video_url:
raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空,无法入库")
# 幂等检查(通过 generation_task_id=job_id 识别,finalize_job 内部也做了一次,这里提前返回简化)
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
@@ -304,11 +311,58 @@ def finalize_render_job(
status="already_finalized",
)
# 封面 URL 解析:按优先级 body.cover_url → job.output_cover_url(smart-cover)→ 兜底自动抽帧,
# 每一级都要校验是公网 HTTP(S) URL(拒绝 blob:/data: 等后端无法访问的 URL)
def _is_valid_public_url(u: str) -> bool:
return bool(u) and u.startswith(("http://", "https://"))
cover_url = ""
for candidate, source in [
((body.cover_url or "").strip(), "body"),
((job.output_cover_url or "").strip(), "job"),
]:
if _is_valid_public_url(candidate):
cover_url = candidate
break
if candidate:
logger.warning(
"finalize 收到无效封面URL(非HTTP,跳过该来源): job_id=%s source=%s url=%s",
job_id,
source,
candidate[:80],
)
# 兜底:body 和 job 都没有可用封面时,后端主动从成片抽一帧
if not cover_url:
try:
fallback_url = (generate_smart_cover(video_url, job_id=job_id, max_frames=5) or "").strip()
except Exception as exc:
logger.warning("finalize 兜底抽帧失败(不阻断入库): job_id=%s err=%s", job_id, exc)
fallback_url = ""
if _is_valid_public_url(fallback_url):
cover_url = fallback_url
try:
job.cover_config = {
**(job.cover_config if isinstance(job.cover_config, dict) else {}),
"mode": "auto_frame",
"url": cover_url,
}
job.output_cover_url = cover_url
db.commit()
except Exception as exc:
logger.warning("finalize 兜底封面写回DB失败(不阻断入库): job_id=%s err=%s", job_id, exc)
elif fallback_url:
logger.warning(
"finalize 兜底抽帧返回了非HTTP URL(忽略): job_id=%s url=%s", job_id, fallback_url[:80]
)
video_title = (body.video_title or "").strip() or None
try:
video = svc.finalize_job(job_id, current_user.user.id)
video = svc.finalize_job(job_id, current_user.user.id, cover_url=cover_url or None, video_title=video_title)
return FinalizeRenderResponse(
video_id=video.id,
cover_url=video.thumbnail_url or job.output_cover_url or "",
cover_url=video.thumbnail_url or cover_url or "",
status="success",
)
except AiAvatarRenderError as exc:
+2
View File
@@ -127,6 +127,7 @@ def _to_response(job) -> ViralVideoJobResponse:
persona_id=job.persona_id,
viral_structure=job.viral_structure,
marketing_purpose=job.marketing_purpose,
tone=getattr(job, "tone", "") or "",
bgm_preference=job.bgm_preference,
duration=job.duration or 15,
user_copy_text=job.user_copy_text,
@@ -404,6 +405,7 @@ def generate_copy(
job.persona_id = request.persona_id or job.persona_id
job.viral_structure = request.viral_structure or job.viral_structure
job.marketing_purpose = request.marketing_purpose or job.marketing_purpose
job.tone = getattr(request, "tone", "") or job.tone
job.bgm_preference = request.bgm_preference or job.bgm_preference
if request.duration:
job.duration = max(5, min(30, int(request.duration)))
+9
View File
@@ -124,6 +124,15 @@ class SmartCoverResponse(BaseModel):
message: str = Field("", description="失败原因(如有)")
class FinalizeRenderRequest(BaseModel):
"""finalize 入库请求体(所有字段可选,缺省时用渲染任务自身状态)."""
cover_url: Optional[str] = Field(
None, description="用户选定的封面公网 URL(必须是 OSS/HTTP(S) 公网地址,blob:/data: URL 无效)"
)
video_title: Optional[str] = Field(None, description="成片库展示标题;为空时用默认 AI数字人_{job_id[:8]}")
class FinalizeRenderResponse(BaseModel):
"""封面选好后点「完成」,正式入库成片库的响应."""
+3
View File
@@ -36,6 +36,7 @@ class ShotScript(BaseModel):
action_details: str = Field(default="", description="人物动作、表情、物品操作细节")
audio_bgm: str = Field(default="", description="环境音+BGM提示")
transition: str = Field(default="硬切", description="转场方式:硬切/淡入淡出/叠化")
voiceover: str = Field(default="", description="该镜头口播台词(纯对白,不含画面描述)")
reference_image_index: int | None = Field(
default=None, description="参考图片索引(0-based,对应上传的第几张产品图)"
)
@@ -91,6 +92,7 @@ class GenerateCopyRequest(BaseModel):
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
tone: str = ""
bgm_preference: str = ""
duration: int = Field(default=15, ge=5, le=30)
user_copy_text: str = ""
@@ -156,6 +158,7 @@ class ViralVideoJobResponse(BaseModel):
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
tone: str = ""
bgm_preference: str = ""
duration: int = 15
user_copy_text: str = ""
@@ -420,19 +420,25 @@ class AiAvatarRenderService:
logger.exception("渲染任务异常 [%s]", job_id)
raise
def _persist_to_library(self, job: AiAvatarRenderJob, cover_url: Optional[str] = None):
def _persist_to_library(
self,
job: AiAvatarRenderJob,
cover_url: Optional[str] = None,
video_title: Optional[str] = None,
):
"""将渲染结果写入成片库,返回 GeneratedVideo 领域对象.
Args:
job: 渲染任务(必须 status=completed 且 output_video_url 非空)
cover_url: 可选的封面 URL 覆盖(finalize 时传入即优先使用,否则取 job.output_cover_url)
video_title: 可选的成片标题覆盖;为空用默认 AI数字人_{job.id[:8]}
"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain.generated_video import GeneratedVideo
clip_name = f"AI数字人_{job.id[:8]}"
clip_name = (video_title or "").strip() or f"AI数字人_{job.id[:8]}"
# AI数字人入口是独立页面,前端可能不传 project_id(无项目概念),
# 兜底为 "ai_avatar" 避免 DB 非空约束/查询问题;generation_task_id 用 render_job_id 便于反查。
clip_project_id = (job.project_id or "").strip() or "ai_avatar"
@@ -456,11 +462,18 @@ class AiAvatarRenderService:
logger.info("[数字人渲染] 成片已入库: clip_id=%s render_job=%s", clip.id, job.id)
return clip
def finalize_job(self, job_id: str, user_id: str, cover_url: Optional[str] = None):
def finalize_job(
self,
job_id: str,
user_id: str,
cover_url: Optional[str] = None,
video_title: Optional[str] = None,
):
"""用户在前端点「完成」后调用:将已 completed 的渲染任务正式入库到成片库.
- 必须 status=completed 才可调用
- cover_url 若传入则优先使用并回写 job.output_cover_url;否则使用 job.output_cover_url(smart-cover/custom-cover 已写入)
- video_title 若传入则作为成片库展示标题;否则使用默认 AI数字人_{job_id[:8]}
- 幂等:已入库则返回已存在的 GeneratedVideo
"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
@@ -499,7 +512,7 @@ class AiAvatarRenderService:
job.updated_at = datetime.now(UTC)
self.db.commit()
return self._persist_to_library(job, cover_url=cover_url)
return self._persist_to_library(job, cover_url=cover_url, video_title=video_title)
def _download_video(self, url: str) -> str:
"""下载视频到临时文件."""
+33 -4
View File
@@ -28,6 +28,7 @@ import {
generateRenderSmartCover,
finalizeRenderJob,
} from "./api/aiAvatar"
import { uploadAssetDirect } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { RenderJob, SentenceTiming } from "./types"
import { buildTitleConfigPayload, buildCoverConfigPayload } from "./utils/contract"
@@ -531,7 +532,7 @@ const AiAvatarPage: React.FC = () => {
setShowCoverModal(false)
}, [])
/* ── 自定义上传封面(本地预览,不单独上传;点完成时一起入库) ── */
/* ── 自定义上传封面:本地预览 + 保存 File 引用,点完成时直传 OSS 再 finalize ── */
const handleUploadCover = useCallback(
(file: File) => {
const url = URL.createObjectURL(file)
@@ -540,13 +541,15 @@ const AiAvatarPage: React.FC = () => {
mode: "upload",
upload_url: url,
thumbnail_url: url,
smart_cover_url: null,
upload_file: file,
}))
setSelectedCoverUrl(url)
},
[state],
)
/* ── 点「完成」:调用 finalize 入库成片库,成功后跳转到成片库 ── */
/* ── 点「完成」:本地上传封面先直传 OSS,再调 finalize 入库成片库 ── */
const handleFinalize = useCallback(async () => {
if (!currentRenderJob?.id) {
message.error("渲染任务不存在")
@@ -556,9 +559,35 @@ const AiAvatarPage: React.FC = () => {
message.warning("请先完成视频生成")
return
}
const { coverConfig } = state
const hasSmartCover = !!coverConfig.smart_cover_url
const hasLocalUpload = coverConfig.mode === "upload" && !!coverConfig.upload_file
setFinalizeLoading(true)
try {
const res = await finalizeRenderJob(currentRenderJob.id)
// 本地上传封面:先走 OSS 直传拿到公网 URL(智能封面已由 /smart-cover 后端上传 OSS,无需重复传;
// 两种都没选则不传 cover_url,后端 finalize 兜底自动抽帧)
let finalCoverUrl: string | undefined
if (hasSmartCover) {
finalCoverUrl = coverConfig.smart_cover_url!
} else if (hasLocalUpload && coverConfig.upload_file) {
const hide = message.loading("正在上传封面...", 0)
try {
const result = await uploadAssetDirect({
file: coverConfig.upload_file,
kind: "image",
})
finalCoverUrl = result.url || undefined
if (!finalCoverUrl) {
message.warning("封面上传未返回URL,将使用视频首帧作为封面")
}
} finally {
hide()
}
}
const res = await finalizeRenderJob(
currentRenderJob.id,
finalCoverUrl ? { cover_url: finalCoverUrl } : undefined,
)
if (res.data?.status === "success" || res.data?.status === "already_finalized") {
message.success("已保存到成片库")
navigate("/app/products")
@@ -574,7 +603,7 @@ const AiAvatarPage: React.FC = () => {
} finally {
setFinalizeLoading(false)
}
}, [currentRenderJob, navigate])
}, [currentRenderJob, navigate, state])
/* ── 配置汇总 ── */
const coverStatus: "not_ready" | "pending" | "selected" = (() => {
+6 -2
View File
@@ -119,8 +119,12 @@ export const generateRenderSmartCover = async (
return response.data
}
/* ── 封面选定后点「完成」正式入库(POST /ai-avatar/render/{job_id}/finalize) ── */
export const finalizeRenderJob = (renderId: string) =>
/* ── 封面选定后点「完成」正式入库(POST /ai-avatar/render/{job_id}/finalize)
* 后端契约:body 支持可选 cover_url(OSS 公网 URL,智能封面已经由后端/smart-cover 写入则不传)
*/
export const finalizeRenderJob = (renderId: string, payload?: { cover_url?: string }) =>
apiClient.post<{ video_id: string; cover_url: string; status: string }>(
`/ai-avatar/render/${renderId}/finalize`,
payload ?? {},
{ timeout: 120_000 }, // 后端兜底抽帧/写入成片库可能耗时较长
)
@@ -73,6 +73,8 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
mode: "auto_frame",
thumbnail_url: coverUrl,
smart_cover_url: coverUrl,
upload_url: null,
upload_file: null,
})
onCoverSelected(coverUrl)
message.success("智能封面已生成")
@@ -113,6 +115,7 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
mode: "upload",
upload_url: url,
thumbnail_url: url,
smart_cover_url: null,
})
onCoverSelected(url)
}
+3
View File
@@ -162,6 +162,8 @@ export interface AiAvatarCoverConfig {
thumbnail_url: string | null
/** 智能封面(MediaKit 选帧)返回的 OSS 非临时 URL(#1822) */
smart_cover_url: string | null
/** 本地上传封面时保存的原始 File 对象,finalize 时先直传 OSS 再带公网 URL */
upload_file?: File | null
}
/* ── 渲染任务 ── */
@@ -215,4 +217,5 @@ export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
upload_url: null,
thumbnail_url: null,
smart_cover_url: null,
upload_file: null,
}
+2
View File
@@ -29,6 +29,8 @@ export default defineConfig({
"src/pages/assets/AssetLibrary.tsx",
"src/pages/voice-materials/VoiceMaterialLibrary.tsx",
"src/pages/viral-video/ViralVideoPage.tsx",
"src/pages/ai-avatar/AiAvatarPage.tsx",
"src/pages/ai-avatar/components/**",
],
// CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标)
// 当前实际:行 ~62% / 分支 ~61% / 函数 ~25%
+279 -57
View File
@@ -358,7 +358,7 @@ def _build_sparse_trusted_urls(
def _validate_copy_result(normalized: dict, dur: int) -> str | None:
"""校验 _script_from_xml 解析后的 copy_result:字数 / 镜头数 / 时间轴。
"""校验 _parse_script 解析后的 copy_result:字数 / 镜头数 / 时间轴。
返回 None 表示通过;否则返回中文 reason 字符串(用于日志)。
"""
@@ -389,9 +389,10 @@ def _validate_copy_result(normalized: dict, dur: int) -> str | None:
def _call_llm_once(
client,
system_prompt: str,
user_prompt: str,
system_prompt: str | None = None,
user_prompt: str | None = None,
*,
messages: list[dict] | None = None,
temperature: float,
max_tokens: int,
timeout: int,
@@ -399,13 +400,16 @@ def _call_llm_once(
) -> str | None:
"""统一封装同步/流式 LLM 调用;返回 raw 文本,失败返回 None。
- messages 直接传多轮消息列表(system/user/assistant,含 few-shot 示例);
- 兼容旧签名:传 system_prompt+user_prompt 自动拼两消息;
- on_delta=None:走同步 chat_completion;
- on_delta 非 None 且 client 有 chat_completion_stream:走流式,节流推送 delta;
- 流式失败自动降级同步。
"""
if not client or not getattr(client, "is_available", False):
return None
messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}]
if messages is None:
messages = [{"role": "system", "content": system_prompt or ""}, {"role": "user", "content": user_prompt or ""}]
# 同步路径
if on_delta is None or not hasattr(client, "chat_completion_stream"):
try:
@@ -436,11 +440,31 @@ def _call_llm_once(
except Exception as e:
logger.warning("[爆款视频] on_delta 回调失败: %s", e)
# 流式强制超时:底层 SDK 的 timeout 对部分流式实现不生效(doubao 实测跑 150s+ 未中断),
# 这里用外层 deadline + 迭代超时双重保护。
_stream_deadline = time.time() + max(timeout or 120, 30)
stream_ok = False
try:
for chunk in client.chat_completion_stream(
stream_iter = client.chat_completion_stream(
messages, temperature=temperature, max_tokens=max_tokens, timeout=timeout or 120
):
)
while True:
remaining = _stream_deadline - time.time()
if remaining <= 0:
logger.warning("[爆款视频] 流式调用超时(外层 deadline),使用已收到的部分内容 tmo=%d", timeout)
break
try:
chunk = next(stream_iter)
except StopIteration:
stream_ok = True
break
except Exception as inner_e:
# 超时类异常直接 break(不降级同步,因为已耗尽超时配额)
err_msg = str(inner_e).lower()
if any(k in err_msg for k in ("timeout", "timed out", "deadline", "超时")):
logger.warning("[爆款视频] 流式调用SDK超时: %s", inner_e)
break
raise
if chunk:
full_parts.append(chunk)
pending.append(chunk)
@@ -450,7 +474,6 @@ def _call_llm_once(
)
if should:
_flush(force=False)
stream_ok = True
except Exception as e:
logger.warning("[爆款视频] 流式调用失败,降级同步: %s", e, exc_info=True)
full_parts = []
@@ -982,33 +1005,6 @@ def _build_images_summary(image_analysis: dict) -> str:
return "\n".join(lines)
def _safe_json_loads(raw: str | dict | list | None):
if raw is None:
return None
if isinstance(raw, (dict, list)):
return raw
if not isinstance(raw, str):
return None
s = raw.strip()
if s.startswith("```"):
s = s.strip("`")
if s.startswith("json"):
s = s[4:].lstrip()
try:
return json.loads(s)
except Exception:
# 尝试截取第一个 { ... } 或 [ ... ]
try:
for open_c, close_c in (("{", "}"), ("[", "]")):
i = s.find(open_c)
j = s.rfind(close_c)
if i >= 0 and j > i:
return json.loads(s[i : j + 1])
except Exception:
pass
return None
def _fallback_script(job: ViralVideoJob) -> dict:
"""脚本生成失败时的极简兜底脚本。基于image_analysis注入真实商品/门店信息,避免生成脱离用户输入的通用文案。"""
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
@@ -1037,7 +1033,11 @@ def _fallback_script(job: ViralVideoJob) -> dict:
if job.user_copy_text:
voiceover = job.user_copy_text
elif _brand_str and _name_str:
voiceover = f"来看看{_brand_str}家的{_name_str}"
# 品牌和名称相同(如 VLM 识别出 brand=御众堂, name=御众堂)时去重,避免"X家的X"重复
if _brand_str == _name_str or _brand_str in _name_str or _name_str in _brand_str:
voiceover = f"来看看{_name_str}"
else:
voiceover = f"来看看{_brand_str}家的{_name_str}"
elif _name_str:
voiceover = f"来看看{_name_str}"
else:
@@ -1056,6 +1056,7 @@ def _fallback_script(job: ViralVideoJob) -> dict:
"audio_bgm": "轻快流行BGM",
"transition": "结束",
"reference_image_index": 0 if job.images else None,
"voiceover": voiceover,
}
]
base["voiceover_script"] = voiceover
@@ -1066,29 +1067,61 @@ def _fallback_script(job: ViralVideoJob) -> dict:
return base
def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
"""解析 v3 XML 分镜为 copy_result;只解析、补字段,不改 LLM 文案。
def _parse_script(raw: str, job: ViralVideoJob) -> dict | None:
"""通用分镜解析器:支持三种 LLM 输出格式。
v3 结构:copy_display_markdown / clips>clip(index)[time_range,voiceover,
visual,reference_image_index] / voiceover_script / theme。
格式A(纯 XML):整段都是 <script>...</script> XML(v3/v4 格式)
格式B(双段式):中文可读说明 + 末尾 <script>...</script> XML 块
格式C(纯中文 markdown):无 <script> 标签,按"镜头1/镜头2..."分段
解析策略:
1. 正则提取最后一个 <script>...</script> 块
2. 有 XML 块 → 走 XML 解析
3. 无 XML 块 → 走中文 markdown 兜底解析
4. 都解析不出 → 返回 None
"""
from packages.application.viral_video import xml_parser as xp
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
ratio = getattr(job, "video_ratio", None) or "9:16"
if not raw:
return None
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
ratio = getattr(job, "video_ratio", None) or "9:16"
image_analysis = normalize_image_analysis(getattr(job, "image_analysis", None))
default_theme = _determine_theme(image_analysis, getattr(job, "marketing_purpose", "") or "")
theme = xp.text_of(raw, "theme") or default_theme
# ── 策略1: 提取 <script>...</script> 块(取最后一个)──
script_blocks = re.findall(r"<script[^>]*>(.*?)</script>", raw, re.DOTALL)
xml_body = script_blocks[-1] if script_blocks else ""
if xml_body:
return _parse_script_xml(xml_body, raw, job, dur, ratio, default_theme)
# ── 策略1.5: 无 <script> 包裹,但含 v3 结构标签(<clips>/<voiceover_script>)──
if re.search(r"<(?:clips|voiceover_script|copy_display_markdown)\b", raw):
return _parse_script_xml(raw, raw, job, dur, ratio, default_theme)
# ── 策略2: 纯中文 markdown 兜底 ──
return _parse_script_markdown(raw, job, dur, ratio, default_theme)
def _parse_script_xml(
xml_body: str,
raw_full: str,
job: ViralVideoJob,
dur: int,
ratio: str,
default_theme: str,
) -> dict | None:
"""XML 路径解析(格式A/B):从 <script> 块内提取 clip 结构。"""
from packages.application.viral_video import xml_parser as xp
theme = xp.text_of(xml_body, "theme") or xp.text_of(raw_full, "theme") or default_theme
base = _empty_copy_result(dur, ratio, theme=theme)
base["overview"]["total_duration"] = dur
base["title"] = theme
display_md = xp.text_of(raw, "copy_display_markdown")
display_md = xp.text_of(xml_body, "copy_display_markdown")
clips = xp.find_all(raw, "clip")
clips = xp.find_all(xml_body, "clip")
shots: list[dict] = []
voice_parts: list[str] = []
for i, c in enumerate(clips):
@@ -1111,6 +1144,7 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
"audio_bgm": xp.text_of(body, "audio_bgm") or "轻快BGM",
"transition": xp.text_of(body, "transition") or ("硬切" if i < len(clips) - 1 else "结束"),
"reference_image_index": ref_idx,
"voiceover": voice or "",
}
shots.append(shot)
@@ -1118,7 +1152,7 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
return None
base["shots"] = shots
voiceover = xp.text_of(raw, "voiceover_script") or "。".join(voice_parts)
voiceover = xp.text_of(xml_body, "voiceover_script") or "。".join(voice_parts)
if not voiceover.strip():
return None
base["voiceover_script"] = voiceover
@@ -1128,23 +1162,188 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
return base
# ── 中文 markdown 兜底解析正则(模块级常量,避免重复编译)────────────
_MD_SHOT_SPLIT_RE = re.compile(
r"(?:^|\n)\s*(?:#{1,4}\s+)?(?:镜头\s*([一二三四五六七八九十\d]+)|第\s*([\d]+)\s*镜)",
re.IGNORECASE,
)
_MD_TIME_RE = re.compile(r"(\d+)\s*[-~到至]\s*(\d+)\s*秒?")
_MD_FIELD_RE = {
"visual": re.compile(r"(?:画面|视觉|镜头画面|shot_type_angle_movement)\s*[::]\s*([^\n]+)"),
"camera": re.compile(r"(?:景别|运镜|镜头运动)\s*[::]\s*([^\n]+)"),
"voiceover": re.compile(r"""(?:口播|对白|台词|配音|旁白|voiceover)\s*[::]\s*""" r"""(?:"([^"]+)"|'([^']+)'|(\S[^
]*?)(?=\n\s*(?:镜头|#|$)|\s*$))"""),
"action": re.compile(r"(?:动作|动作细节|action)\s*[::]\s*([^\n]+)"),
"audio_bgm": re.compile(r"(?:音效|BGM|背景音乐|audio)\s*[::]\s*([^\n]+)"),
"transition": re.compile(r"(?:转场|过渡)\s*[::]\s*([^\n]+)"),
"reference": re.compile(r"参考@图片(\d+)|参考图[::]?\s*(\d+)"),
"theme": re.compile(r"(?:视频主题|主题|title)\s*[::]\s*([^\n]+)"),
"full_voiceover": re.compile(r"(?:口播全文|完整口播|口播稿)\s*[::]\s*\n?([\s\S]+?)(?=\n\s*(?:镜头|#{1,4}\s|\Z))"),
}
def _parse_script_markdown(
raw: str,
job: ViralVideoJob,
dur: int,
ratio: str,
default_theme: str,
) -> dict | None:
"""中文 markdown 兜底解析(格式C):按"镜头1/镜头一/第1镜"等分割。"""
# 提取 theme
theme = default_theme
theme_match = _MD_FIELD_RE["theme"].search(raw)
if theme_match:
theme = theme_match.group(1).strip() or default_theme
# 按镜头标识分割文本
boundaries = []
for m in _MD_SHOT_SPLIT_RE.finditer(raw):
shot_num_str = m.group(1) or m.group(2)
# 中文数字转阿拉伯数字
cn_map = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10}
if shot_num_str in cn_map:
shot_num = cn_map[shot_num_str]
else:
try:
shot_num = int(shot_num_str)
except (ValueError, TypeError):
shot_num = 0
boundaries.append((m.start(), shot_num))
if not boundaries:
return None
# 分割各镜头块
shot_blocks: list[tuple[int, str]] = []
for idx, (start, num) in enumerate(boundaries):
end = boundaries[idx + 1][0] if idx + 1 < len(boundaries) else len(raw)
block_text = raw[start:end]
shot_blocks.append((num, block_text))
base = _empty_copy_result(dur, ratio, theme=theme)
base["overview"]["total_duration"] = dur
base["title"] = theme
shots: list[dict] = []
voice_parts: list[str] = []
for i, (_, block) in enumerate(shot_blocks):
# 时间范围
time_match = _MD_TIME_RE.search(block)
if time_match:
t_start, t_end = time_match.group(1), time_match.group(2)
time_range = f"{t_start}-{t_end}秒"
else:
n = len(shot_blocks)
seg = dur / n if n else dur
time_range = f"{int(i * seg)}-{int((i + 1) * seg)}秒"
# 景别/运镜
visual = ""
camera = ""
vis_m = _MD_FIELD_RE["visual"].search(block)
if vis_m:
visual = vis_m.group(1).strip()
cam_m = _MD_FIELD_RE["camera"].search(block)
if cam_m:
camera = cam_m.group(1).strip()
shot_type = camera or visual or "中景平视,固定镜头"
# 口播/对白
voice = ""
vo_m = _MD_FIELD_RE["voiceover"].search(block)
if vo_m:
voice = (vo_m.group(1) or vo_m.group(2) or vo_m.group(3) or "").strip()
# 去除末尾可能的标点
voice = voice.rstrip(",。、;;")
# 动作
action = ""
act_m = _MD_FIELD_RE["action"].search(block)
if act_m:
action = act_m.group(1).strip()
# 音效/BGM
audio = ""
audio_m = _MD_FIELD_RE["audio_bgm"].search(block)
if audio_m:
audio = audio_m.group(1).strip()
# 转场
trans = ""
trans_m = _MD_FIELD_RE["transition"].search(block)
if trans_m:
trans = trans_m.group(1).strip()
# 参考图
ref_idx = None
ref_m = _MD_FIELD_RE["reference"].search(block)
if ref_m:
ref_str = ref_m.group(1) or ref_m.group(2)
try:
ref_idx = int(ref_str) - 1 # 用户视角从1开始,内部从0开始
if ref_idx < 0:
ref_idx = None
except (ValueError, TypeError):
ref_idx = None
if voice:
voice_parts.append(voice)
# scene_and_dialogue 保持原有拼接格式
visual_desc = visual or "中景平视,固定镜头"
scene_and_dialogue = (voice or "") if not visual_desc else f"{visual_desc}|{voice or ''}"
shot = {
"time_range": time_range,
"shot_type_angle_movement": shot_type,
"scene_and_dialogue": scene_and_dialogue,
"action_details": action,
"audio_bgm": audio or "轻快BGM",
"transition": trans or ("硬切" if i < len(shot_blocks) - 1 else "结束"),
"reference_image_index": ref_idx,
"voiceover": voice,
}
shots.append(shot)
if not shots:
return None
base["shots"] = shots
# 顶层 voiceover_script:先尝试从"口播全文"段提取,否则拼接各镜头口播
full_vo_match = _MD_FIELD_RE["full_voiceover"].search(raw)
if full_vo_match:
voiceover = full_vo_match.group(1).strip()
else:
voiceover = "".join(voice_parts)
if not voiceover.strip():
return None
base["voiceover_script"] = voiceover
base["final_copy"] = voiceover
base["suggested_copy"] = voiceover
base["copy_display_markdown"] = voiceover
return base
def _step_script_generation(
job: ViralVideoJob,
image_analysis: dict,
on_delta: Optional[Callable[[str, str], None]] = None,
) -> dict:
"""步骤: 意图理解 + 分镜生成一次完成(PR2 直线化版:单一重试 for 循环 + XML-only 解析)。
"""步骤: 意图理解 + 分镜生成一次完成(单一重试 for 循环 + 双格式解析)。
重试链路:fast@0.8 → fast@0.6 → pro@0.7 → _finalize_fallback_script。
- _parse_script 支持纯 XML / 双段式(中文+XML)/ 纯中文 markdown 三种格式;
- 删除 JSON fallback 解析路径(PR1 已统一 XML);
- 删除 compress-retry 冗余分支(前3次失败再压缩意义不大);
- _try_gen / _try_gen_stream / _stream_chat_with_fallback 合并到 _call_llm_once + _validate_copy_result。
"""
try:
from packages.application.viral_video.prompt_loader import (
build_messages,
get_template,
render_system_prompt,
render_user_prompt,
)
from packages.shared.ai_router import ai_router
except ImportError:
@@ -1158,7 +1357,6 @@ def _step_script_generation(
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
template = get_template("storyboard")
system_prompt = render_system_prompt(template)
marketing_purpose = getattr(job, "marketing_purpose", "") or "未指定"
theme_hint = _determine_theme(image_analysis, marketing_purpose)
@@ -1202,7 +1400,7 @@ def _step_script_generation(
# 字数/镜头数约束(统一由 constants 生成,注入到 prompt 占位符 {duration_constraint_text})
_duration_constraint_text = build_duration_constraint_text(dur)
user_prompt = render_user_prompt(
llm_messages = build_messages(
template,
marketing_purpose=marketing_purpose or "未指定",
marketing_purpose_hint=mp_hint,
@@ -1222,6 +1420,8 @@ def _step_script_generation(
video_style_section=style_section,
duration_constraint_text=_duration_constraint_text,
)
llm_messages[0]["content"]
llm_messages[-1]["content"]
# primary 模型(DB 配置的首选,豆包 Seed 2.1 Pro,稳但稍慢)
client_primary = ai_router.get_llm_client("storyboard", variant="primary")
@@ -1229,9 +1429,11 @@ def _step_script_generation(
client_fast = ai_router.get_llm_client("storyboard", variant="lite")
if not client_fast or not getattr(client_fast, "is_available", False):
client_fast = client_primary
# 单档超时 + 总 deadline:doubao 流式偶发 90~120s,总 deadline 必须覆盖 2 次 fast + 1 次 pro
# (原 180s 太短,两次 fast 调用就耗尽,pro-0.7 永远跑不到)
fast_tmo = int(os.environ.get("VIRAL_VIDEO_SCRIPT_FAST_TIMEOUT", "90"))
pro_tmo = int(os.environ.get("VIRAL_VIDEO_SCRIPT_PRO_TIMEOUT", "60"))
deadline = time.time() + 180
pro_tmo = int(os.environ.get("VIRAL_VIDEO_SCRIPT_PRO_TIMEOUT", "120"))
deadline = time.time() + int(os.environ.get("VIRAL_VIDEO_SCRIPT_TOTAL_DEADLINE", "300"))
# ── 重试 for 循环(替代原嵌套 if + 两个重复 _try_gen) ─────────────────
attempts: list[tuple] = [
@@ -1253,8 +1455,7 @@ def _step_script_generation(
logger.info("[爆款视频] 分镜生成 model=%s label=%s timeout=%d", client.model, label, tmo)
raw = _call_llm_once(
client,
system_prompt,
user_prompt,
messages=llm_messages,
temperature=temp,
max_tokens=max_tok,
timeout=tmo,
@@ -1262,7 +1463,7 @@ def _step_script_generation(
)
if not raw:
continue
copy_result = _script_from_xml(raw, job)
copy_result = _parse_script(raw, job)
if copy_result is None:
logger.warning("[viral][script] attempt=%s XML解析失败 raw_len=%d", label, len(raw))
continue
@@ -1277,6 +1478,27 @@ def _step_script_generation(
shots_cnt,
)
return copy_result
# P0: 口播超长是最常见的失败原因(v4 编导模板字数天然偏多)。LLM 重试通常仍会超长,
# 直接按句号切句截断,避免浪费 fast/pro 配额和 deadline。
if reason.startswith("口播超长") and shots_cnt >= 1:
spec = get_duration_spec(dur)
_orig_vo = (copy_result.get("voiceover_script") or "").strip()
_truncated = _truncate_voiceover(_orig_vo, spec["max_chars"])
if len(_truncated) >= spec["min_chars"] and len(_truncated) <= spec["max_chars"]:
# 同步更新 shots 里每个镜头的口播(按原比例分配截断)
copy_result["voiceover_script"] = _truncated
# 简单策略:把完整口播放在第一个镜头的 scene_and_dialogue 前面,
# 其余镜头保留画面描述,不重复塞口播(TTS 从 voiceover_script 取,不走 scene)
_recheck = _validate_copy_result(copy_result, dur)
if _recheck is None or _recheck.startswith("口播超长") is False:
logger.info(
"[爆款视频] 口播超长自动截断 label=%s orig=%d trunc=%d shots=%d ok",
label,
voiceover_len,
len(_truncated),
shots_cnt,
)
return copy_result
logger.warning(
"[viral][script] attempt=%s validate_fail=%s voiceover=%d shots=%d",
label,
@@ -948,6 +948,7 @@ class ViralVideoJobModel(Base):
persona_id = Column(String(36), nullable=False, default="")
viral_structure = Column(String(50), nullable=False, default="")
marketing_purpose = Column(String(100), nullable=False, default="")
tone = Column(String(50), nullable=False, default="")
bgm_preference = Column(String(50), nullable=False, default="")
duration = Column(Integer, nullable=False, default=30)
user_copy_text = Column(Text, nullable=False, default="")
@@ -38,6 +38,7 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
persona_id=model.persona_id or "",
viral_structure=model.viral_structure or "",
marketing_purpose=model.marketing_purpose or "",
tone=getattr(model, "tone", "") or "",
bgm_preference=model.bgm_preference or "",
duration=model.duration or 15,
user_copy_text=model.user_copy_text or "",
@@ -91,6 +92,7 @@ class SQLAlchemyViralVideoJobRepository:
persona_id=job.persona_id,
viral_structure=job.viral_structure,
marketing_purpose=job.marketing_purpose,
tone=getattr(job, "tone", "") or "",
bgm_preference=job.bgm_preference,
duration=job.duration,
user_copy_text=job.user_copy_text,
@@ -159,6 +161,7 @@ class SQLAlchemyViralVideoJobRepository:
model.persona_id = job.persona_id
model.viral_structure = job.viral_structure
model.marketing_purpose = job.marketing_purpose
model.tone = getattr(job, "tone", "") or ""
model.bgm_preference = job.bgm_preference
model.duration = job.duration
model.fusion_level = job.fusion_level
+21 -2
View File
@@ -4,7 +4,8 @@
1. 分句:先按 。!?; 句末标点切分;整段无句末标点且超长时按逗号等语义停顿二次切分
2. 调 DoubaoClient.chat_completion 分析每句表情(emo: 0-7 八种全部开放, intensity: 0-1)
3. 结果 LRU 缓存(文案 hash → 情绪列表)
4. LLM 失败/超时/格式错 → 返回空列表(降级中性表情,不阻塞生成)
4. LLM 失败/超时/格式错 → analyze 层预分句全中性兜底;build_timeline 识别全兜底特征后
返回空串不传 emo_timeline(避免压制 GPU 端自身表情逻辑),不阻塞生成
5. TTS 完成后按字数比例或 sentence_timings 对齐成秒级 timeline
"""
@@ -31,6 +32,11 @@ EMO_SURPRISE = 6
EMO_CONTEMPT = 7
ALLOWED_EMOS = set(range(8)) # 8 种表情全部开放(0-7);超出范围的非法值仍会被降级为中性
# 传给 Ditto 的 emo_timeline 强度下限:GPU 端表情混合公式修复后,intensity>=0.3
# 目标情绪才能压过中性;LLM 常给陈述句打 0.1-0.2,信号太弱、表情效果一眼假。
# 构造 timeline entry 时统一保底到该值(不影响 analyze 层 LLM 原始输出与日志排查)
MIN_TIMELINE_INTENSITY = 0.35
# ── 分句正则 ─────────────────────────────────────────────────────
_SENT_SPLIT_RE = re.compile(r"(?<=[。!?;!?;])\s*")
# 二次切分:逗号/分号/顿号/冒号等语义停顿(lookbehind 保留标点在前句末尾)
@@ -82,7 +88,8 @@ class EmotionTimelineEntry:
self.start = round(start, 2)
self.end = round(end, 2)
self.emo = emo
self.intensity = round(intensity, 2)
# 强度保底:低于 MIN_TIMELINE_INTENSITY 的信号在 GPU 混合公式中压不过中性
self.intensity = round(max(intensity, MIN_TIMELINE_INTENSITY), 2)
def to_dict(self) -> dict[str, Any]:
return {
@@ -361,6 +368,18 @@ class DittoEmotionService:
segments = self.analyze(text)
if not segments:
return ""
# LLM 失败/异常时 _cached_analyze 会兜底返回"全中性(emo=4)+低强度(<=0.1)"的
# 预分句结果;把这种"明确的全中性指令"传给 GPU 会压制 Ditto 端
# use_script_emo/emo_global 的自身处理,比不传更糟糕。识别全兜底特征后
# 返回空串(payload 不带 emo_timeline),交由 GPU 端自行决定表情。
# 注意:LLM 真实分析即使全中性,intensity 一般 >0.1,不会被误伤。
if all(s.emo == EMO_NEUTRAL and s.intensity <= 0.1 for s in segments):
logger.info(
"[ditto_emotion] 分析结果全为中性低强度(疑似 LLM 兜底),"
"emo_timeline 置空交由 GPU 端处理: text=%.80s",
text,
)
return ""
if sentence_timings:
entries = align_timeline_by_timings(segments, sentence_timings, audio_duration)
else:
@@ -7,11 +7,11 @@ from __future__ import annotations
# 时长规格:key=duration(秒),value=对应约束
DURATION_SPEC: dict[int, dict[str, int]] = {
5: {"min_chars": 12, "max_chars": 20, "min_shots": 1, "max_shots": 2},
10: {"min_chars": 25, "max_chars": 40, "min_shots": 2, "max_shots": 3},
15: {"min_chars": 38, "max_chars": 60, "min_shots": 3, "max_shots": 4},
20: {"min_chars": 50, "max_chars": 80, "min_shots": 4, "max_shots": 5},
30: {"min_chars": 75, "max_chars": 120, "min_shots": 6, "max_shots": 8},
5: {"min_chars": 12, "max_chars": 25, "min_shots": 1, "max_shots": 2},
10: {"min_chars": 25, "max_chars": 50, "min_shots": 2, "max_shots": 3},
15: {"min_chars": 38, "max_chars": 75, "min_shots": 3, "max_shots": 4},
20: {"min_chars": 50, "max_chars": 100, "min_shots": 4, "max_shots": 5},
30: {"min_chars": 75, "max_chars": 150, "min_shots": 6, "max_shots": 8},
}
@@ -38,8 +38,8 @@ def build_duration_constraint_text(dur: int) -> str:
spec = get_duration_spec(dur)
return (
f"视频时长{dur}秒。口播总字数严格控制在{spec['min_chars']}~{spec['max_chars']}字之间"
f"(中文正常口播语速约2.5~4字/秒,{dur}秒={dur * 4}字为硬上限,宁可少写也不要多写,"
f"超长会导致TTS配音超出视频时长而失败)。"
f"(中文口播语速约3~5字/秒,{dur}秒={spec['max_chars']}字为硬上限,必须严格遵守,"
f"超长会被截断导致语义不完整)。"
f"分镜镜头数{spec['min_shots']}~{spec['max_shots']}个,每个镜头的time_range首尾相接,"
f"不得跳秒或重叠,每个镜头时长≥2秒。"
)
@@ -179,11 +179,46 @@ def _safe_format(text: str, kwargs: dict) -> str:
return text
def render_user_prompt(template: PromptTemplate, **kwargs) -> str:
"""填充 user_prompt_template 占位符,缺键原样保留不报错。"""
return _safe_format(template.user_prompt_template, kwargs)
def render_system_prompt(template: PromptTemplate, **kwargs) -> str:
"""copy_fusion 等 system_prompt 含运行时变量时填充。"""
return _safe_format(template.system_prompt, kwargs)
"""system_prompt 含运行时变量时填充;example_output 存在时作为 few-shot 样例追加到末尾。"""
body = _safe_format(template.system_prompt, kwargs)
example = (template.example_output or "").strip()
if not example:
return body
# 追加一个 assistant 角色样例消息由调用方拼成多轮;system 里只能带规则,example 统一由 user 侧追加
return body
def render_user_prompt(template: PromptTemplate, **kwargs) -> str:
r"""填充 user_prompt_template 占位符;example_output 存在时作为 few-shot 样例追加到 user 消息末尾,
配合 \_build_example_exchange 拼多轮 messages,LLM 收到完整「示例输入→示例输出」对齐格式。"""
body = _safe_format(template.user_prompt_template, kwargs)
example = (template.example_output or "").strip()
if not example:
return body
return body
def build_messages(template: PromptTemplate, **kwargs) -> list[dict]:
"""构造传给 LLM 的 messages 列表:system + (user, assistant[example]) + user。
- 第 1 条 system:规则
- 如果 template.example_output 非空,追加一轮 few-shot:user 给示例 user_prompt 渲染结果,assistant 给示例输出
- 最后 user:当前任务真实 user_prompt
这样 LLM 既能看到规则,又能看到一个完整「输入→输出」样例,格式遵从度大幅提升。
"""
sys_body = render_system_prompt(template, **kwargs)
user_body = render_user_prompt(template, **kwargs)
example = (template.example_output or "").strip()
messages: list[dict] = [{"role": "system", "content": sys_body}]
if example:
# 用一个通用「请参考以下示例输出格式」作为 few-shot user 前缀
messages.append(
{
"role": "user",
"content": "以下是一个符合规范的完整示例输出,请严格参照其格式、字段、风格来生成:",
}
)
messages.append({"role": "assistant", "content": example})
messages.append({"role": "user", "content": user_body})
return messages
+3 -8
View File
@@ -17,9 +17,8 @@ from typing import Optional
from packages.application.viral_video import xml_parser as xp
from packages.application.viral_video.prompt_loader import (
build_messages,
get_template,
render_system_prompt,
render_user_prompt,
)
from packages.application.viral_video.prompts import COMPLIANCE_BLACKLIST
from packages.application.viral_video.schemas import ReviewIssue, ReviewResult
@@ -119,13 +118,9 @@ class Reviewer:
if not self.client or not getattr(self.client, "is_available", True):
return None
template = get_template("review")
system = render_system_prompt(template)
user = render_user_prompt(template, voiceover_script=voiceover_script)
messages = build_messages(template, voiceover_script=voiceover_script)
raw = self.client.chat_completion(
[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
messages,
temperature=0.2,
max_tokens=1024,
timeout=25,
+1
View File
@@ -92,6 +92,7 @@ class ViralVideoJob:
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
tone: str = ""
bgm_preference: str = ""
duration: int = 15 # v1.6: 默认15秒,上限30秒(Seedance 2.5 单次最大30s)
user_copy_text: str = ""
+116
View File
@@ -0,0 +1,116 @@
# 爆款视频模块全面审计报告
**审计日期**: 2026-10-14
**审计分支**: fix/viral-script-dual-format-parser
**审计人**: CodeAct Agent
**基准 commit**: 5e5a9b6 (#2294)
---
## 一、Parser 改造说明
### 改造内容
将 `_script_from_xml`(仅支持纯 XML)改造为 `_parse_script`,支持三种 LLM 输出格式:
| 格式 | 描述 | 解析路径 |
|------|------|----------|
| A: 纯 XML | `<script>...</script>` 包裹(v3/v4) | `_parse_script_xml` |
| B: 双段式 | 中文 markdown 说明 + 末尾 `<script>` XML 块 | `_parse_script_xml`(提取最后一个 script 块) |
| C: 纯中文 markdown | 无 `<script>` 标签,按"镜头1/镜头2..."分段 | `_parse_script_markdown` |
| (兼容) 无包裹 v3 XML | 含 `<clips>/<voiceover_script>` 但无 `<script>` 包裹 | `_parse_script_xml`(整个 raw 当 XML body) |
### 解析策略
1. 正则 `r'<script[^>]*>(.*?)</script>'` (DOTALL) 提取所有 `<script>` 块,取最后一个
2. 有 XML 块 → 走 XML 解析(`_parse_script_xml`)
3. 无 `<script>` 但含 v3 结构标签(`<clips>/<voiceover_script>/<copy_display_markdown>`)→ 整个 raw 走 XML 解析(兼容旧格式)
4. 都不匹配 → 走中文 markdown 兜底解析(`_parse_script_markdown`)
5. 都解析不出 → 返回 None
### 代码变更
- `_script_from_xml` → `_parse_script`(入口函数)
- 新增 `_parse_script_xml`(XML 路径解析,从原函数抽出)
- 新增 `_parse_script_markdown`(中文 markdown 兜底解析)
- 模块级常量:`_MD_SHOT_SPLIT_RE`, `_MD_TIME_RE`, `_MD_FIELD_RE` 等正则
---
## 二、Seedance voiceover bug 修复说明
### 问题
`_assemble_seedance_prompt`(~line 1990/2010)循环 shots 时读 `s.get("voiceover", "")`,但原 `_script_from_xml` 输出的 shot dict 没有 `voiceover` 字段——口播被拼进了 `scene_and_dialogue = f"{visual}|{voice}"`,导致 Seedance/Wan prompt 中口播为空。
### 修复
1. `_parse_script_xml`: 每个 shot dict 显式设置 `"voiceover": voice or ""`
2. `_parse_script_markdown`: 同样输出 `"voiceover": voice` 字段
3. `_fallback_script`: shot dict 加 `"voiceover": voiceover`(就是那一句 voiceover)
4. `_assemble_seedance_prompt` 的 Wan 和 Seedance 两个分支都已正确读 `s.get("voiceover")`,无需改动
### 验证
- 新增测试 `TestShotVoiceoverField`(3 个用例)确认 XML/Markdown/Fallback 三条路径的 shot 都有 `voiceover`
- 新增测试 `test_assemble_seedance_uses_shot_voiceover` 确认 Seedance prompt 能取到口播
---
## 三、冗余代码清理情况
| 项目 | 状态 | 说明 |
|------|------|------|
| `_script_from_xml` → `_parse_script` 重命名 | ✅ 完成 | 调用点(line ~1484)已同步更新 |
| `_safe_json_loads` | ⚠️ 保留 | 已不在主流程使用(JSON fallback 路径已删),但 2 个单测引用它,暂保留 |
| `_validate_copy_result` docstring | ✅ 更新 | 引用从 `_script_from_xml` 改为 `_parse_script` |
| `_step_script_generation` docstring | ✅ 更新 | 反映双格式解析支持 |
| reviewer.py voiceover_script XML 标签 | ✅ 确认无需改 | `<voiceover_script>` 仅存在于 reviewer 自己构造的 messages 里,跟 storyboard 输出格式无关 |
| JSON fallback 残留 | ✅ 无残留 | 主流程无 JSON 解析路径,`_safe_json_loads` 仅测试引用 |
---
## 四、全面审计结果
### 审计清单 9 项
| # | 审计项 | 验证结果 | 发现问题 | 修复情况 |
|---|--------|----------|----------|----------|
| 1 | **用户配置参数链路** | ✅ 基本完整 | `tone` 字段未在 API schema (GenerateCopyRequest) 中定义,但 worker 用 `getattr(job, "tone", "") or "亲切自然"` 优雅降级,不影响功能 | 记录为 minor issue,不影响本次 PR |
| 2 | **图片分析→文案→review→渲染全流程** | ✅ 链路完整 | `_step_image_analysis` → `_step_script_generation` → `_step_review` → `_step_tts` → `_step_render` → `_step_upload`,每步有错误处理和降级 | 无需修复 |
| 3 | **三视图机制** | ✅ 链路完整 | `pre_trusted_images` → `_build_character_section` → Seedream 三视图 → reference_images 通道。代码注释明确标注"当前为空实现,仅保留扩展点" | 无需修复 |
| 4 | **few-shot example_output** | ✅ 正确使用 | `prompt_loader.build_messages` 在 storyboard 和 reviewer 两个场景都正确注入 few-shot(system→user示例→assistant示例→user任务) | 无需修复 |
| 5 | **字数/时长约束** | ✅ 链路正确 | `constants.DURATION_SPEC` → `build_duration_constraint_text` → `{duration_constraint_text}` 占位符注入 → `_validate_copy_result` 校验 → `_truncate_voiceover` 截断 | 无需修复 |
| 6 | **重试/超时机制** | ✅ 链路正确 | deadline 300s + fast@0.8 → fast@0.6 → pro@0.7 三档重试 + 流式外层 deadline + next() 超时 + 口播超长自动截断(`_truncate_voiceover`)| 无需修复 |
| 7 | **fallback 脚本** | ✅ brand==name 去重生效 | `_fallback_script` 中 `_brand_str == _name_str or _brand_str in _name_str or _name_str in _brand_str` 三重判断确保去重(#2294 修复) | 本次新增 `voiceover` 字段到 shot dict |
| 8 | **TTS/口型驱动链路** | ✅ 链路完整 | `voiceover_script` → `_step_tts` (CosyVoice) → 音频 URL → `_step_render` (Seedance) → 视频 → Ditto/MuseTalk 口型。voiceover 字段修复后链路更完整 | voiceover bug 修复(本次 PR) |
| 9 | **review 审核阶段** | ✅ 逻辑正确 | `reviewer.py` 的 `_llm_review_inner` 用 `build_messages(template, voiceover_script=...)` 送审,XML 解析 `<passed>/<issues>`,审核失败合并本地规则后返回;LLM 不可用时降级放行(带 warning issue)| 无需修复 |
### 发现的 Minor Issues(不阻塞本次 PR)
1. **`tone` 参数未暴露到 API**: `GenerateCopyRequest` schema 没有 `tone` 字段,但 worker 用 `getattr` 优雅降级到默认值 "亲切自然"。前端如需自定义语气,需后续版本补 schema 字段。
2. **`_safe_json_loads` 死代码**: 主流程已不使用(JSON fallback 在 PR1 已删),但 2 个单测 (`test_safe_json_loads_*`) 引用它。建议下个 PR 清理。
3. **API ShotScript schema 缺 `voiceover` 字段**: `apps/api/app/schemas/viral_video.py` 的 `ShotScript` 模型没有 `voiceover` 字段定义。前端目前从 `scene_and_dialogue` 拆口播,后续可考虑暴露 `voiceover` 字段给前端。
---
## 五、测试覆盖情况
### 新增测试文件: `tests/unit/test_viral_video_dual_parser.py`
| 测试类 | 用例数 | 覆盖范围 |
|--------|--------|----------|
| `TestDualFormatParsing` | 2 | 双段式(中文+XML)解析 + voiceover 字段 |
| `TestPureXmlParsing` | 2 | 纯 XML 兼容性 + voiceover 字段 |
| `TestPureMarkdownParsing` | 9 | 纯中文 markdown 解析(shots/theme/voiceover/time_range/audio/transition/reference/拼接/中文数字) |
| `TestShotVoiceoverField` | 3 | XML/Fallback/Seedance 三条路径的 voiceover 字段 |
| `TestParameterPassing` | 3 | marketing_purpose/target_audience/fusion_level 参数传递 |
| `TestMarkdownParseFailures` | 4 | 解析失败返回 None(纯文本/空字符串/None/无口播) |
**合计**: 23 个新增用例,全部通过。
### 回归测试
- 全量 viral 相关测试: **269 passed, 2 skipped**
- 覆盖了 test_viral_video.py / test_viral_video_p0.py / test_viral_video_p2_errors.py / test_viral_video_prompt_system.py / test_viral_video_routes.py / test_viral_video_v8_bugs.py / test_viral_video_wiring.py / test_viral_video_ws.py / test_viral_video_models.py
---
## 六、PR 信息
- **分支**: `fix/viral-script-dual-format-parser`
- **Base**: `develop`
- **PR**: 待创建
+293
View File
@@ -449,3 +449,296 @@ class TestRenderSmartCoverRoute:
assert result.cover_url == ""
# 失败时不写 cover_config / 不 commit
mock_db.commit.assert_not_called()
class TestFinalizeRoute:
"""POST /renders/{job_id}/finalize — 封面选好后正式入库成片库."""
def _make_existing_video(self, video_id="video-1", cover_url="https://oss/existing-cover.jpg"):
v = MagicMock()
v.id = video_id
v.thumbnail_url = cover_url
return v
def _make_finalized_video(self, video_id="video-new", thumbnail_url="https://oss/final-cover.jpg"):
v = MagicMock()
v.id = video_id
v.thumbnail_url = thumbnail_url
return v
def test_finalize_job_not_found_returns_404(self):
"""任务不存在 → 404."""
from app.api.routes.ai_avatar_render import finalize_render_job
from app.schemas.ai_avatar_render import FinalizeRenderRequest
from fastapi import HTTPException
mock_service = MagicMock()
mock_service.get_render_job.return_value = None
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_user = _make_mock_auth_user()
body = FinalizeRenderRequest()
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
finalize_render_job(job_id="render-missing", body=body, current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 404
assert "不存在" in exc_info.value.detail
def test_finalize_job_not_completed_returns_400(self):
"""任务未 completed(如 processing)→ 400."""
from app.api.routes.ai_avatar_render import finalize_render_job
from app.schemas.ai_avatar_render import FinalizeRenderRequest
from fastapi import HTTPException
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="processing", output_video_url="https://oss/v.mp4")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_user = _make_mock_auth_user()
body = FinalizeRenderRequest()
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
finalize_render_job(job_id="render-1", body=body, current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 400
assert "先完成视频生成" in exc_info.value.detail
def test_finalize_empty_video_url_returns_400(self):
"""已 completed 但 output_video_url 为空 → 400."""
from app.api.routes.ai_avatar_render import finalize_render_job
from app.schemas.ai_avatar_render import FinalizeRenderRequest
from fastapi import HTTPException
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="completed", output_video_url=" ")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_user = _make_mock_auth_user()
body = FinalizeRenderRequest()
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
finalize_render_job(job_id="render-1", body=body, current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 400
assert "URL 为空" in exc_info.value.detail
def test_finalize_idempotent_returns_existing(self):
"""已 finalize 过 → 幂等返回 existing 记录,不重复入库."""
from app.api.routes.ai_avatar_render import finalize_render_job
from app.schemas.ai_avatar_render import FinalizeRenderRequest
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="completed", output_video_url="https://oss/v.mp4")
mock_service.get_render_job.return_value = mock_job
existing = self._make_existing_video()
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = existing
mock_user = _make_mock_auth_user()
body = FinalizeRenderRequest()
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
result = finalize_render_job(job_id="render-1", body=body, current_user=mock_user, db=mock_db)
assert result.status == "already_finalized"
assert result.video_id == "video-1"
assert result.cover_url == "https://oss/existing-cover.jpg"
mock_service.finalize_job.assert_not_called()
def test_finalize_empty_body_uses_smart_cover_url(self):
"""空 body 且 job.output_cover_url 已由 smart-cover 写入 → 直接用,不抽帧."""
from app.api.routes.ai_avatar_render import finalize_render_job
from app.schemas.ai_avatar_render import FinalizeRenderRequest
mock_service = MagicMock()
mock_job = _make_mock_render_job(
status="completed",
output_video_url="https://oss/v.mp4",
output_cover_url="https://oss/smart-cover.jpg",
)
mock_service.get_render_job.return_value = mock_job
finalized = self._make_finalized_video(thumbnail_url="https://oss/smart-cover.jpg")
mock_service.finalize_job.return_value = finalized
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_user = _make_mock_auth_user()
body = FinalizeRenderRequest()
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch("app.api.routes.ai_avatar_render.generate_smart_cover") as mock_gen,
):
result = finalize_render_job(job_id="render-1", body=body, current_user=mock_user, db=mock_db)
mock_gen.assert_not_called()
mock_service.finalize_job.assert_called_once()
call_kwargs = mock_service.finalize_job.call_args.kwargs
assert call_kwargs["cover_url"] == "https://oss/smart-cover.jpg"
assert call_kwargs.get("video_title") is None
assert result.status == "success"
assert result.cover_url == "https://oss/smart-cover.jpg"
def test_finalize_body_cover_url_takes_priority(self):
"""body.cover_url 优先于 job.output_cover_url."""
from app.api.routes.ai_avatar_render import finalize_render_job
from app.schemas.ai_avatar_render import FinalizeRenderRequest
mock_service = MagicMock()
mock_job = _make_mock_render_job(
status="completed",
output_video_url="https://oss/v.mp4",
output_cover_url="https://oss/smart-cover.jpg",
)
mock_service.get_render_job.return_value = mock_job
finalized = self._make_finalized_video(thumbnail_url="https://oss/custom-cover.jpg")
mock_service.finalize_job.return_value = finalized
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_user = _make_mock_auth_user()
body = FinalizeRenderRequest(cover_url="https://oss/custom-cover.jpg")
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch("app.api.routes.ai_avatar_render.generate_smart_cover") as mock_gen,
):
result = finalize_render_job(job_id="render-1", body=body, current_user=mock_user, db=mock_db)
mock_gen.assert_not_called()
call_kwargs = mock_service.finalize_job.call_args.kwargs
assert call_kwargs["cover_url"] == "https://oss/custom-cover.jpg"
assert result.cover_url == "https://oss/custom-cover.jpg"
def test_finalize_blob_url_is_ignored(self):
"""body 传 blob:/data: 等非 HTTP URL → 忽略,走已有 cover/兜底."""
from app.api.routes.ai_avatar_render import finalize_render_job
from app.schemas.ai_avatar_render import FinalizeRenderRequest
mock_service = MagicMock()
mock_job = _make_mock_render_job(
status="completed",
output_video_url="https://oss/v.mp4",
output_cover_url="https://oss/smart-cover.jpg",
)
mock_service.get_render_job.return_value = mock_job
finalized = self._make_finalized_video(thumbnail_url="https://oss/smart-cover.jpg")
mock_service.finalize_job.return_value = finalized
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_user = _make_mock_auth_user()
body = FinalizeRenderRequest(cover_url="blob:http://localhost/xxx")
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch("app.api.routes.ai_avatar_render.generate_smart_cover") as mock_gen,
):
result = finalize_render_job(job_id="render-1", body=body, current_user=mock_user, db=mock_db)
mock_gen.assert_not_called()
call_kwargs = mock_service.finalize_job.call_args.kwargs
# blob URL 被忽略,fallback 到 job.output_cover_url
assert call_kwargs["cover_url"] == "https://oss/smart-cover.jpg"
assert result.cover_url == "https://oss/smart-cover.jpg"
def test_finalize_no_cover_fallback_to_smart_cover(self):
"""无 body.cover_url 且 job.output_cover_url 为空 → 兜底自动抽帧."""
from app.api.routes.ai_avatar_render import finalize_render_job
from app.schemas.ai_avatar_render import FinalizeRenderRequest
mock_service = MagicMock()
mock_job = _make_mock_render_job(
status="completed",
output_video_url="https://oss/v.mp4",
output_cover_url="",
)
mock_job.cover_config = {}
mock_service.get_render_job.return_value = mock_job
finalized = self._make_finalized_video(thumbnail_url="https://oss/fallback-cover.jpg")
mock_service.finalize_job.return_value = finalized
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_user = _make_mock_auth_user()
body = FinalizeRenderRequest()
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch(
"app.api.routes.ai_avatar_render.generate_smart_cover",
return_value="https://oss/fallback-cover.jpg",
) as mock_gen,
):
result = finalize_render_job(job_id="render-1", body=body, current_user=mock_user, db=mock_db)
mock_gen.assert_called_once_with("https://oss/v.mp4", job_id="render-1", max_frames=5)
call_kwargs = mock_service.finalize_job.call_args.kwargs
assert call_kwargs["cover_url"] == "https://oss/fallback-cover.jpg"
# 兜底成功应回写 job.cover_config 和 output_cover_url
assert mock_job.output_cover_url == "https://oss/fallback-cover.jpg"
assert mock_job.cover_config["mode"] == "auto_frame"
assert mock_job.cover_config["url"] == "https://oss/fallback-cover.jpg"
assert mock_db.commit.called
assert result.cover_url == "https://oss/fallback-cover.jpg"
def test_finalize_video_title_override(self):
"""body.video_title 覆盖默认 AI数字人_xxx."""
from app.api.routes.ai_avatar_render import finalize_render_job
from app.schemas.ai_avatar_render import FinalizeRenderRequest
mock_service = MagicMock()
mock_job = _make_mock_render_job(
status="completed",
output_video_url="https://oss/v.mp4",
output_cover_url="https://oss/c.jpg",
)
mock_service.get_render_job.return_value = mock_job
finalized = self._make_finalized_video()
mock_service.finalize_job.return_value = finalized
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_user = _make_mock_auth_user()
body = FinalizeRenderRequest(video_title="我和我的养生馆")
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch("app.api.routes.ai_avatar_render.generate_smart_cover") as mock_gen,
):
result = finalize_render_job(job_id="render-1", body=body, current_user=mock_user, db=mock_db)
mock_gen.assert_not_called()
call_kwargs = mock_service.finalize_job.call_args.kwargs
assert call_kwargs["video_title"] == "我和我的养生馆"
assert result.status == "success"
def test_finalize_service_error_mapped_to_http(self):
"""service 抛 AiAvatarRenderError → 按 code 映射 HTTP 状态码."""
from app.api.routes.ai_avatar_render import finalize_render_job
from app.schemas.ai_avatar_render import FinalizeRenderRequest
from app.services.ai_avatar_render_service import AiAvatarRenderError
from fastapi import HTTPException
mock_service = MagicMock()
mock_job = _make_mock_render_job(
status="completed",
output_video_url="https://oss/v.mp4",
output_cover_url="https://oss/c.jpg",
)
mock_service.get_render_job.return_value = mock_job
mock_service.finalize_job.side_effect = AiAvatarRenderError("成片视频不存在", code="OutputVideoMissing")
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_user = _make_mock_auth_user()
body = FinalizeRenderRequest()
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch("app.api.routes.ai_avatar_render.generate_smart_cover") as mock_gen,
):
with pytest.raises(HTTPException) as exc_info:
finalize_render_job(job_id="render-1", body=body, current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 400
mock_gen.assert_not_called()
+112
View File
@@ -8,8 +8,11 @@ from unittest.mock import MagicMock, patch
import pytest
from packages.application.ditto_emotion_service import (
EMO_ANGER,
EMO_HAPPY,
EMO_NEUTRAL,
EMO_SAD,
EMO_SURPRISE,
DittoEmotionService,
EmotionSegment,
_parse_emotion_json,
@@ -362,3 +365,112 @@ class TestWarnIfSuspiciousAllNeutral:
with caplog.at_level("WARNING", logger="packages.application.ditto_emotion_service"):
_warn_if_suspicious_all_neutral("家人们!", [])
assert not caplog.records
# ── build_timeline 全中性兜底置空(GPU端表情公式配套修复1)─────────
class TestBuildTimelineAllNeutralFallback:
"""LLM 兜底特征(全 emo=4 且 intensity<=0.1)→ 不传 emo_timeline,交 GPU 端自行处理."""
def test_llm_failure_fallback_returns_empty(self):
"""LLM 调用失败 → _cached_analyze 兜底全中性0.1 → build_timeline 置空."""
import packages.application.ditto_emotion_service as m
svc = _make_service(enabled=True)
m._cached_analyze.cache_clear()
try:
with patch.object(svc, "_call_llm", return_value=[]):
result = svc.build_timeline("全兜底置空检测专用独一无二文案甲。全兜底置空检测专用文案乙。", 5.0)
assert result == ""
finally:
m._cached_analyze.cache_clear()
def test_all_neutral_low_intensity_returns_empty(self):
"""LLM 返回全 emo=4 且 intensity<=0.1(兜底特征)→ 置空."""
import packages.application.ditto_emotion_service as m
svc = _make_service(enabled=True)
m._cached_analyze.cache_clear()
fake_reply = json.dumps(
[
{"text": "低强度中性检测专用文案甲", "emo": 4, "intensity": 0.1},
{"text": "低强度中性检测专用文案乙", "emo": 4, "intensity": 0.05},
]
)
try:
with patch.object(svc, "_call_llm", return_value=_parse_emotion_json(fake_reply)):
assert svc.build_timeline("低强度中性检测专用文案甲。低强度中性检测专用文案乙。", 5.0) == ""
finally:
m._cached_analyze.cache_clear()
def test_all_neutral_but_higher_intensity_kept(self):
"""全中性但 intensity>0.1(LLM 真实输出特征)→ 正常生成 timeline,不误伤."""
import packages.application.ditto_emotion_service as m
svc = _make_service(enabled=True)
m._cached_analyze.cache_clear()
fake_reply = json.dumps(
[
{"text": "高强度中性保留检测专用文案甲", "emo": 4, "intensity": 0.3},
{"text": "高强度中性保留检测专用文案乙", "emo": 4, "intensity": 0.2},
]
)
try:
with patch.object(svc, "_call_llm", return_value=_parse_emotion_json(fake_reply)):
result = svc.build_timeline("高强度中性保留检测专用文案甲。高强度中性保留检测专用文案乙。", 4.0)
data = json.loads(result)
assert len(data) == 2
assert all(item["emo"] == 4 for item in data)
finally:
m._cached_analyze.cache_clear()
# ── timeline entry 强度保底 0.35(GPU端表情公式配套修复3)─────────
class TestTimelineIntensityFloor:
def test_low_intensity_floored(self):
segs = [EmotionSegment("ab", EMO_HAPPY, 0.2)]
entries = align_timeline_by_length(segs, 2.0)
assert entries[0].intensity == 0.35
def test_floor_boundary_values(self):
segs = [
EmotionSegment("a", EMO_HAPPY, 0.1),
EmotionSegment("b", EMO_SAD, 0.34),
EmotionSegment("c", EMO_ANGER, 0.35),
]
entries = align_timeline_by_length(segs, 3.0)
assert entries[0].intensity == 0.35
assert entries[1].intensity == 0.35
assert entries[2].intensity == 0.35
def test_high_intensity_unchanged(self):
segs = [EmotionSegment("ab", EMO_HAPPY, 0.6)]
entries = align_timeline_by_length(segs, 2.0)
assert entries[0].intensity == 0.6
def test_floor_applies_in_timings_path(self):
segs = [EmotionSegment("hello", EMO_SAD, 0.15)]
timings = [{"start": 0.0, "end": 2.0}]
entries = align_timeline_by_timings(segs, timings, 2.0)
assert entries[0].intensity == 0.35
# ── timings 数量不匹配回退字数占比(GPU端表情公式配套修复2,固化行为)───
class TestAlignTimingsMismatchCharRatio:
def test_mismatch_falls_back_to_char_ratio_with_emo_mapping(self):
"""LLM 情绪分句(按逗号)与 TTS 分句(句末标点)数量不一致时,
必须回退到按情绪分句字数占比分配,保证 emo 与时间段一一对应."""
segs = [
EmotionSegment("a" * 6, EMO_HAPPY, 0.5),
EmotionSegment("b" * 2, EMO_SAD, 0.5),
EmotionSegment("c" * 2, EMO_SURPRISE, 0.5),
]
timings = [{"start": 0.0, "end": 10.0}] # len 1 != 3
entries = align_timeline_by_timings(segs, timings, 10.0)
assert len(entries) == 3
assert entries[0].start == 0.0
assert entries[0].end == 6.0
assert entries[1].start == 6.0
assert entries[1].end == 8.0
assert entries[2].start == 8.0
assert entries[2].end == 10.0
assert [e.emo for e in entries] == [EMO_HAPPY, EMO_SAD, EMO_SURPRISE]
+562
View File
@@ -0,0 +1,562 @@
"""双格式 parser + voiceover 字段 + markdown 兜底解析测试。
覆盖:
1. 双段式(中文 markdown + <script> XML 块)解析
2. 纯 XML 解析(已有测试覆盖,确认兼容)
3. 纯中文 markdown 解析(镜头1/镜头2/关键字段)
4. Shot voiceover 字段存在性 + _assemble_seedance_prompt 取值
5. 参数传递集成测试(marketing_purpose/target_audience/fusion_level)
6. markdown 解析失败返回 None
"""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
import pytest
# ── 通用 fixtures ──────────────────────────────────────────────────────
@pytest.fixture
def mock_job():
"""构造一个最小可用的 ViralVideoJob mock。"""
job = MagicMock()
job.duration = 15
job.video_ratio = "9:16"
job.marketing_purpose = "品牌推广"
job.image_analysis = {"images": []}
job.images = []
job.user_copy_text = ""
job.fusion_level = "ai_polish"
return job
def _import_wv(monkeypatch):
"""导入 worker viral_video 模块(mock celery/db 依赖)。"""
for mod in ["worker_app.celery_app", "worker_app.db"]:
monkeypatch.setitem(sys.modules, mod, MagicMock())
import celery
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
from worker_app.tasks import viral_video as wv
return wv
# ════════════════════════════════════════════════════════════════════════
# 1. 双段式解析:中文 markdown + <script> XML 块
# ════════════════════════════════════════════════════════════════════════
class TestDualFormatParsing:
"""格式B:中文可读说明 + 末尾 <script>...</script> XML 块。"""
DUAL_FORMAT_INPUT = """## 视频总览
这是一个关于口红试色的短视频,目标受众是年轻女性。
### 逐秒镜头拆解
- 整体风格:暖色调、近景特写
- 节奏:前3秒给出钩子,中段展示产品,结尾引导行动
### 硬性约束
- 无字幕无水印
- 人物一致性
---
以下是符合规范的分镜脚本:
<script>
<copy_display_markdown><![CDATA[今天试一支超显白的口红,姐妹们看过来]]></copy_display_markdown>
<clips>
<clip index="1">
<time_range>0-5秒</time_range>
<voiceover>姐妹们,今天试一支超显白的色号</voiceover>
<visual>近景平视,暖光下手持口红缓慢展示</visual>
<reference_image_index>0</reference_image_index>
</clip>
<clip index="2">
<time_range>5-10秒</time_range>
<voiceover>你看这个颜色,涂上去整个手都亮了</voiceover>
<visual>特写,嘴唇涂抹口红过程</visual>
<reference_image_index>1</reference_image_index>
</clip>
<clip index="3">
<time_range>10-15秒</time_range>
<voiceover>真的超好看,推荐给你们</voiceover>
<visual>中景,微笑展示最终效果</visual>
<reference_image_index>0</reference_image_index>
</clip>
</clips>
<voiceover_script>姐妹们,今天试一支超显白的色号。你看这个颜色,涂上去整个手都亮了。真的超好看,推荐给你们。</voiceover_script>
<theme>口红试色分享</theme>
</script>"""
def test_dual_format_extracts_xml_block(self, monkeypatch, mock_job):
"""双段式输入应正确提取 <script> 块并按 XML 路径解析。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.DUAL_FORMAT_INPUT, mock_job)
assert result is not None
assert result["voiceover_script"]
assert "姐妹们" in result["voiceover_script"]
assert len(result["shots"]) == 3
assert result["title"] == "口红试色分享"
def test_dual_format_shots_have_voiceover(self, monkeypatch, mock_job):
"""双段式解析后每个 shot 都应有独立 voiceover 字段。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.DUAL_FORMAT_INPUT, mock_job)
assert result is not None
for shot in result["shots"]:
assert "voiceover" in shot
assert isinstance(shot["voiceover"], str)
assert "超显白" in result["shots"][0]["voiceover"]
assert "推荐" in result["shots"][2]["voiceover"]
# ════════════════════════════════════════════════════════════════════════
# 2. 纯 XML 解析(兼容性测试)
# ════════════════════════════════════════════════════════════════════════
class TestPureXmlParsing:
"""格式A:纯 XML,与原有 v3 格式完全一致。"""
PURE_XML = """<script>
<copy_display_markdown><![CDATA[分享一款口红]]></copy_display_markdown>
<clips>
<clip index="1">
<time_range>0-5秒</time_range>
<voiceover>大家好,今天分享一款口红</voiceover>
<visual>近景平视,缓慢推镜</visual>
<action_details>手持口红特写</action_details>
<audio_bgm>轻快流行BGM</audio_bgm>
<transition>硬切</transition>
<reference_image_index>0</reference_image_index>
</clip>
<clip index="2">
<time_range>5-10秒</time_range>
<voiceover>颜色特别好看</voiceover>
<visual>特写,固定镜头</visual>
<audio_bgm>轻快BGM继续</audio_bgm>
<transition>硬切</transition>
<reference_image_index>1</reference_image_index>
</clip>
<clip index="3">
<time_range>10-15秒</time_range>
<voiceover>很显白,推荐给大家</voiceover>
<visual>中景,微笑展示</visual>
<audio_bgm>轻快BGM结束</audio_bgm>
<transition>结束</transition>
<reference_image_index>0</reference_image_index>
</clip>
</clips>
<voiceover_script>大家好呀,今天来给大家分享一款超显白的口红。颜色特别好看很显气质,真心推荐给姐妹们</voiceover_script>
<theme>口红分享</theme>
</script>"""
def test_pure_xml_parses_correctly(self, monkeypatch, mock_job):
"""纯 XML 格式应能正确解析出所有字段。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.PURE_XML, mock_job)
assert result is not None
assert result["title"] == "口红分享"
assert len(result["shots"]) == 3
assert "口红" in result["voiceover_script"]
def test_pure_xml_shots_have_voiceover(self, monkeypatch, mock_job):
"""纯 XML 路径也应为每个 shot 生成 voiceover 字段。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.PURE_XML, mock_job)
assert result is not None
assert result["shots"][0]["voiceover"] == "大家好,今天分享一款口红"
assert result["shots"][1]["voiceover"] == "颜色特别好看"
assert result["shots"][2]["voiceover"] == "很显白,推荐给大家"
# ════════════════════════════════════════════════════════════════════════
# 3. 纯中文 markdown 解析(格式C 兜底)
# ════════════════════════════════════════════════════════════════════════
class TestPureMarkdownParsing:
"""格式C:纯中文结构化 markdown,无 <script> 标签。"""
MARKDOWN_INPUT = """## 视频主题:咖啡店探店
这是一段关于咖啡店探店的短视频分镜。
### 镜头1
- 时间范围:0-5秒
- 景别:近景平视
- 运镜:缓慢推镜
- 口播:"大家好,今天带你们来一家超有氛围的咖啡店"
- 画面:暖光木质装修的咖啡店门面,镜头缓缓推近
- 动作:推门进入
- 音效:轻爵士BGM
- 转场:硬切
- 参考图:1
### 镜头2
- 时间范围:5-10秒
- 景别:特写
- 运镜:固定镜头
- 口播:"你看这杯拿铁,拉花也太好看了吧"
- 画面:咖啡师制作拿铁特写,奶泡拉花清晰可见
- 动作:咖啡师拉花过程
- 音效:咖啡机声+轻爵士
- 转场:叠化
- 参考图:2
### 镜头3
- 时间范围:10-15秒
- 景别:中景
- 运镜:缓慢拉远
- 口播:"推荐给你们,周末来坐坐吧"
- 画面:店内全景,客人悠闲品咖啡,暖光氛围
- 动作:端起杯子品尝
- 音效:轻爵士BGM渐弱
- 转场:结束
- 参考@图片1"""
def test_markdown_parses_shots(self, monkeypatch, mock_job):
"""纯中文 markdown 应能解析出3个镜头。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
assert result is not None
assert len(result["shots"]) == 3
def test_markdown_extracts_theme(self, monkeypatch, mock_job):
"""应从"视频主题"提取 theme。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
assert result is not None
assert "咖啡店" in result["title"]
def test_markdown_extracts_voiceover(self, monkeypatch, mock_job):
"""每个镜头应提取口播到 voiceover 字段。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
assert result is not None
assert "咖啡店" in result["shots"][0]["voiceover"]
assert "拿铁" in result["shots"][1]["voiceover"]
assert "推荐" in result["shots"][2]["voiceover"]
def test_markdown_extracts_time_range(self, monkeypatch, mock_job):
"""应正确提取各镜头的 time_range。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
assert result is not None
assert result["shots"][0]["time_range"] == "0-5秒"
assert result["shots"][1]["time_range"] == "5-10秒"
assert result["shots"][2]["time_range"] == "10-15秒"
def test_markdown_extracts_audio_bgm(self, monkeypatch, mock_job):
"""应正确提取音效/BGM。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
assert result is not None
assert "爵士" in result["shots"][0]["audio_bgm"] or "BGM" in result["shots"][0]["audio_bgm"]
def test_markdown_extracts_transition(self, monkeypatch, mock_job):
"""应正确提取转场。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
assert result is not None
assert result["shots"][0]["transition"] == "硬切"
assert result["shots"][1]["transition"] == "叠化"
assert result["shots"][2]["transition"] == "结束"
def test_markdown_extracts_reference_image(self, monkeypatch, mock_job):
"""应正确提取参考图索引(从1开始转为0-based)。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
assert result is not None
assert result["shots"][0]["reference_image_index"] == 0 # 参考图:1 -> 0
assert result["shots"][1]["reference_image_index"] == 1 # 参考图:2 -> 1
# 镜头3用"参考@图片1"格式 -> 0
assert result["shots"][2]["reference_image_index"] == 0
def test_markdown_voiceover_script_concatenated(self, monkeypatch, mock_job):
"""voiceover_script 应是各镜头口播的拼接。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
assert result is not None
assert "咖啡店" in result["voiceover_script"]
assert "拿铁" in result["voiceover_script"]
assert "推荐" in result["voiceover_script"]
def test_markdown_chinese_number_shots(self, monkeypatch, mock_job):
"""支持中文数字镜头标识(镜头一/镜头二/...)。"""
wv = _import_wv(monkeypatch)
md_cn = """## 视频主题:测试
### 镜头一
- 时间范围:0-5秒
- 口播:"第一段口播内容很长足够通过校验"
- 画面:近景展示
### 镜头二
- 时间范围:5-10秒
- 口播:"第二段口播内容也要足够长才行"
- 画面:特写展示
### 镜头三
- 时间范围:10-15秒
- 口播:"第三段口播内容结尾"
- 画面:中景展示
"""
result = wv._parse_script(md_cn, mock_job)
assert result is not None
assert len(result["shots"]) == 3
# ════════════════════════════════════════════════════════════════════════
# 4. Shot voiceover 字段 + _assemble_seedance_prompt
# ════════════════════════════════════════════════════════════════════════
class TestShotVoiceoverField:
"""确认 parser 输出的每个 shot dict 都有独立 voiceover 字段。"""
def test_xml_path_shot_voiceover(self, monkeypatch, mock_job):
"""XML 路径解析后 shot 有 voiceover 字段。"""
wv = _import_wv(monkeypatch)
xml = """<script>
<clips>
<clip index="1">
<time_range>0-5秒</time_range>
<voiceover>第一句口播</voiceover>
<visual>近景展示</visual>
<reference_image_index>0</reference_image_index>
</clip>
<clip index="2">
<time_range>5-10秒</time_range>
<voiceover>第二句口播</voiceover>
<visual>特写展示</visual>
<reference_image_index>-1</reference_image_index>
</clip>
<clip index="3">
<time_range>10-15秒</time_range>
<voiceover>第三句口播</voiceover>
<visual>中景展示</visual>
<reference_image_index>0</reference_image_index>
</clip>
</clips>
<voiceover_script>第一句口播。第二句口播。第三句口播。</voiceover_script>
<theme>测试主题</theme>
</script>"""
result = wv._parse_script(xml, mock_job)
assert result is not None
for shot in result["shots"]:
assert "voiceover" in shot
assert result["shots"][0]["voiceover"] == "第一句口播"
assert result["shots"][1]["voiceover"] == "第二句口播"
assert result["shots"][2]["voiceover"] == "第三句口播"
def test_fallback_script_shot_voiceover(self, monkeypatch, mock_job):
"""_fallback_script 的 shot dict 也应有 voiceover 字段。"""
wv = _import_wv(monkeypatch)
fb = wv._fallback_script(mock_job)
assert fb["shots"]
for shot in fb["shots"]:
assert "voiceover" in shot
assert shot["voiceover"] == fb["voiceover_script"]
def test_assemble_seedance_uses_shot_voiceover(self, monkeypatch, mock_job):
"""_assemble_seedance_prompt 应能取到 shot 的 voiceover 字段。"""
wv = _import_wv(monkeypatch)
mock_job.video_model = "seedance-2.5"
cr = {
"overview": {"theme": "测试", "total_duration": 15, "aspect_ratio": "9:16"},
"scene_and_lighting": "明亮",
"shots": [
{
"time_range": "0-5秒",
"shot_type_angle_movement": "近景",
"scene_and_dialogue": "画面描述|测试口播",
"action_details": "展示",
"audio_bgm": "BGM",
"transition": "硬切",
"reference_image_index": 0,
"voiceover": "这是第一句口播",
},
{
"time_range": "5-10秒",
"shot_type_angle_movement": "特写",
"scene_and_dialogue": "画面描述",
"action_details": "",
"audio_bgm": "BGM",
"transition": "硬切",
"reference_image_index": 0,
"voiceover": "这是第二句口播",
},
{
"time_range": "10-15秒",
"shot_type_angle_movement": "中景",
"scene_and_dialogue": "画面描述",
"action_details": "",
"audio_bgm": "BGM",
"transition": "结束",
"reference_image_index": 0,
"voiceover": "这是第三句口播",
},
],
"hard_constraints": ["无字幕"],
"negative_prompts": ["水印"],
}
prompt = wv._assemble_seedance_prompt(cr, mock_job)
# Seedance 格式应包含口播内容
assert "第一句口播" in prompt
assert "第二句口播" in prompt
# ════════════════════════════════════════════════════════════════════════
# 5. 参数传递集成测试(3个核心参数)
# ════════════════════════════════════════════════════════════════════════
class TestParameterPassing:
"""验证核心用户参数从 job -> build_messages 的传递链路。"""
def test_marketing_purpose_in_messages(self, monkeypatch):
"""marketing_purpose='门店引流' 应出现在 build_messages 的 user 消息中。"""
from packages.application.viral_video.prompt_loader import build_messages, get_template, invalidate
invalidate()
template = get_template("storyboard")
assert template is not None
messages = build_messages(
template,
marketing_purpose="门店引流",
marketing_purpose_hint="【营销目的:门店引流】以到店为核心",
industry="美容",
image_summary="",
theme_hint="门店引流",
duration="15",
image_count="2",
aspect_ratio="9:16",
tone="亲切自然",
target_audience="25-35岁女性",
target_customer_hint="【目标人群】25-35岁女性",
persona_hint="【人设】亲切自然",
viral_structure_hint="【爆款结构】自由组织",
language_hint="【语言】中文",
extra_requirements="(AI 创作)",
video_style_section="",
duration_constraint_text="视频时长15秒。口播总字数严格控制在38~75字之间",
)
user_msg = messages[-1]["content"]
assert "门店引流" in user_msg
def test_target_audience_in_messages(self, monkeypatch):
"""target_audience='年轻妈妈' 应出现在 build_messages 的 user 消息中。"""
from packages.application.viral_video.prompt_loader import build_messages, get_template, invalidate
invalidate()
template = get_template("storyboard")
assert template is not None
messages = build_messages(
template,
marketing_purpose="品牌推广",
marketing_purpose_hint="【营销目的】品牌推广",
industry="母婴",
image_summary="",
theme_hint="母婴品牌",
duration="15",
image_count="1",
aspect_ratio="9:16",
tone="温柔亲切",
target_audience="年轻妈妈",
target_customer_hint="【目标人群】年轻妈妈群体",
persona_hint="【人设】温柔",
viral_structure_hint="【爆款结构】自由",
language_hint="【语言】中文",
extra_requirements="(AI 创作)",
video_style_section="",
duration_constraint_text="视频时长15秒。",
)
user_msg = messages[-1]["content"]
assert "年轻妈妈" in user_msg
def test_fusion_level_user_primary_in_messages(self, monkeypatch):
"""fusion_level=user_primary + user_copy_text 应生成'必须原封不动使用'字样。"""
from packages.application.viral_video.prompt_loader import build_messages, get_template, invalidate
invalidate()
template = get_template("storyboard")
assert template is not None
# 模拟 worker 里 fusion_level=user_primary 时构造的 extra_req
extra_req = (
"【用户指定文案,必须原封不动使用,一个字都不要改,"
"你只需要根据文案生成分镜脚本的镜头描述和画面,"
"copy_display_markdown里的文案必须与用户原文完全一致】:\n"
"我们的新店开业啦,全场八折优惠"
)
messages = build_messages(
template,
marketing_purpose="促销活动",
marketing_purpose_hint="【营销目的】促销",
industry="零售",
image_summary="",
theme_hint="新店开业",
duration="15",
image_count="0",
aspect_ratio="9:16",
tone="热情",
target_audience="周边居民",
target_customer_hint="【目标人群】周边居民",
persona_hint="【人设】热情",
viral_structure_hint="【爆款结构】自由",
language_hint="【语言】中文",
extra_requirements=extra_req,
video_style_section="",
duration_constraint_text="视频时长15秒。",
)
user_msg = messages[-1]["content"]
assert "必须原封不动使用" in user_msg
assert "新店开业" in user_msg
# ════════════════════════════════════════════════════════════════════════
# 6. markdown 解析失败返回 None
# ════════════════════════════════════════════════════════════════════════
class TestMarkdownParseFailures:
"""无法解析的输入应返回 None。"""
def test_plain_text_returns_none(self, monkeypatch, mock_job):
"""纯文本不含镜头标识,应返回 None。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script("这是一段普通文本,没有任何镜头标识。", mock_job)
assert result is None
def test_empty_string_returns_none(self, monkeypatch, mock_job):
"""空字符串应返回 None。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script("", mock_job)
assert result is None
def test_none_returns_none(self, monkeypatch, mock_job):
"""None 输入应返回 None。"""
wv = _import_wv(monkeypatch)
result = wv._parse_script(None, mock_job)
assert result is None
def test_markdown_without_voiceover_returns_none(self, monkeypatch, mock_job):
"""有镜头标识但没有口播内容,voiceover_script 为空,应返回 None。"""
wv = _import_wv(monkeypatch)
md = """### 镜头1
- 画面:近景展示产品
### 镜头2
- 画面:特写展示产品
### 镜头3
- 画面:中景展示产品
"""
result = wv._parse_script(md, mock_job)
assert result is None
-14
View File
@@ -84,20 +84,6 @@ class TestScriptGenerationV16:
assert out["final_copy"] == out["voiceover_script"]
assert len(out["shots"]) >= 1
def test_safe_json_loads_parses_fenced_code(self):
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
fenced = '```json\n{"voiceover_script": "你好", "shots": []}\n```'
out = _safe_json_loads(fenced)
assert out is not None
assert out["voiceover_script"] == "你好"
def test_safe_json_loads_handles_none(self):
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
assert _safe_json_loads(None) is None
assert _safe_json_loads("not json") is None
def test_assemble_seedance_prompt_contains_sections(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _assemble_seedance_prompt
+2 -2
View File
@@ -130,7 +130,7 @@ class TestTemplates:
duration_constraint_text=build_duration_constraint_text(15),
)
assert "去油快" in rendered
assert "38~60字" in rendered
assert "38~75字" in rendered
# ── constants ─────────────────────────────────────────────────────────────
@@ -149,7 +149,7 @@ class TestConstants:
def test_duration_constraint_text_mentions_chars_and_shots(self):
text = build_duration_constraint_text(15)
assert "38" in text and "60" in text
assert "38" in text and "75" in text
assert "镜头" in text
def test_get_expected_shot_count(self):
+1
View File
@@ -45,6 +45,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"persona_id": "",
"viral_structure": "",
"marketing_purpose": "",
"tone": "",
"bgm_preference": "",
"duration": 15,
"user_copy_text": "",
+136 -2
View File
@@ -34,6 +34,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"persona_id": "",
"viral_structure": "",
"marketing_purpose": "",
"tone": "",
"bgm_preference": "",
"duration": 15,
"user_copy_text": "",
@@ -264,9 +265,9 @@ class TestBug2VoiceoverWordCount:
assert "{duration_constraint_text}" in _STORYBOARD_USER
# 15 秒规格 min_chars=38, max_chars=60,约束文本中含具体数字
t15 = build_duration_constraint_text(15)
assert "38" in t15 and "60" in t15
assert "38" in t15 and "75" in t15
t30 = build_duration_constraint_text(30)
assert "75" in t30 and "120" in t30
assert "75" in t30 and "150" in t30
# get_duration_spec 兜底:越界 clamp 到 5/30,None/0 默认 15 秒档位
assert get_duration_spec(5)["min_chars"] == 12
assert get_duration_spec(999)["min_shots"] == 6
@@ -1120,3 +1121,136 @@ class TestIssue2249UserParamsFix:
assert "marketing_purpose" in _STORYBOARD_SYSTEM or "persona" in _STORYBOARD_SYSTEM
assert "不能忽略" in _STORYBOARD_SYSTEM or "必须" in _STORYBOARD_SYSTEM
class TestV4ValidationDeadlineFixes:
"""v4 编导分镜四叠 bug 回归测试(PR #2294):
1. max_chars 放宽(15s 60→75)
2. _fallback_script brand==name 去重
3. 口播超长自动截断接受
4. build_messages few-shot 多轮消息
"""
def test_duration_spec_relaxed(self):
"""验证 15s max_chars 已放宽到 75,30s 到 150"""
from packages.application.viral_video.constants import get_duration_spec
assert get_duration_spec(15)["max_chars"] == 75
assert get_duration_spec(15)["min_chars"] == 38
assert get_duration_spec(10)["max_chars"] == 50
assert get_duration_spec(20)["max_chars"] == 100
assert get_duration_spec(30)["max_chars"] == 150
assert get_duration_spec(5)["max_chars"] == 25
def test_fallback_script_brand_name_dedup(self):
"""_fallback_script: brand==name 时不应拼 'X家的X'"""
from unittest.mock import MagicMock, patch
from apps.worker.worker_app.tasks import viral_video as vv
job = MagicMock()
job.duration = 15
job.video_ratio = "9:16"
job.user_copy_text = None
job.images = []
# 构造 VLM 输出 brand==name
job.image_analysis = {"images": [{"type": "product", "name": "御众堂", "brand": "御众堂", "has_person": False}]}
job.marketing_purpose = ""
job.style_guide = None
job.persona_id = ""
job.viral_structure = ""
job.language = "zh-CN"
job.industry = ""
job.target_customer = ""
job.fusion_level = "ai_polish"
with patch.object(vv, "normalize_image_analysis", return_value=job.image_analysis):
result = vv._fallback_script(job)
vo = result["voiceover_script"]
# 不应出现"御众堂家的御众堂"重复
assert "御众堂家的御众堂" not in vo
assert "来看看御众堂" in vo
assert len(result["shots"]) == 1
def test_fallback_script_brand_name_different(self):
"""_fallback_script: brand≠name 时仍正常拼接 'X家的Y'"""
from unittest.mock import MagicMock, patch
from apps.worker.worker_app.tasks import viral_video as vv
job = MagicMock()
job.duration = 15
job.video_ratio = "9:16"
job.user_copy_text = None
job.images = []
job.image_analysis = {
"images": [{"type": "product", "name": "修护精华", "brand": "御众堂", "has_person": False}]
}
job.marketing_purpose = ""
job.style_guide = None
job.persona_id = ""
job.viral_structure = ""
job.language = "zh-CN"
job.industry = ""
job.target_customer = ""
job.fusion_level = "ai_polish"
with patch.object(vv, "normalize_image_analysis", return_value=job.image_analysis):
result = vv._fallback_script(job)
vo = result["voiceover_script"]
assert "御众堂家的修护精华" in vo
def test_build_messages_with_example(self):
"""build_messages: example_output 非空时生成 4 条消息 few-shot"""
from packages.application.viral_video.prompt_loader import (
PromptTemplate,
build_messages,
)
tpl = PromptTemplate(
name="storyboard",
prompt_type="storyboard",
version=99,
system_prompt="系统:你是分镜师",
user_prompt_template="请为{product}写脚本",
example_output="<script>示例输出</script>",
)
msgs = build_messages(tpl, product="测试商品")
assert len(msgs) == 4
assert msgs[0]["role"] == "system"
assert msgs[1]["role"] == "user"
assert "示例" in msgs[1]["content"]
assert msgs[2]["role"] == "assistant"
assert "<script>示例输出</script>" in msgs[2]["content"]
assert msgs[3]["role"] == "user"
assert "测试商品" in msgs[3]["content"]
def test_build_messages_without_example(self):
"""build_messages: example_output 为空时退化为 2 条消息"""
from packages.application.viral_video.prompt_loader import (
PromptTemplate,
build_messages,
)
tpl = PromptTemplate(
name="storyboard",
prompt_type="storyboard",
version=99,
system_prompt="系统:你是分镜师",
user_prompt_template="请写脚本",
example_output="",
)
msgs = build_messages(tpl)
assert len(msgs) == 2
assert msgs[0]["role"] == "system"
assert msgs[1]["role"] == "user"
def test_truncate_voiceover_preserves_full_sentences(self):
"""_truncate_voiceover: 按句号切句保留完整句,不截到半句"""
from apps.worker.worker_app.tasks.viral_video import _truncate_voiceover
text = "今天给大家推荐一款好产品。它的效果特别棒,用完皮肤水水润润的。大家一定要试试哦。"
out = _truncate_voiceover(text, 25)
assert len(out) <= 25
assert out.endswith("。")
assert "推荐一款好产品" in out