Compare commits

...

8 Commits

Author SHA1 Message Date
xiaoxia 0200d499ef Merge pull request 'feat(viral-video): v1.6 single-shot Seedance + director storyboard #2124' (#2126) from fix/2124-storyboard-pipeline-single-shot into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / 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 / Check push changed paths (push) Successful in 16s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m9s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m24s
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 4m49s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m44s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m17s
CI/CD Pipeline / Validate - Style (push) Successful in 5m47s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 6m22s
AI Code Review / AI Code Review (pull_request) Successful in 7m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m34s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m55s
CI/CD Pipeline / Unit Tests (push) Successful in 12m50s
CI/CD Pipeline / Validate - Security (push) Successful in 13m30s
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-01 14:33:24 +08:00
CI Bot eda3a3a540 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m33s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging 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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 35s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m38s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m15s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m49s
AI Code Review / AI Code Review (pull_request) Successful in 6m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m46s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 11m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m43s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 19m21s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 35s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m43s
2026-10-01 06:02:55 +00:00
xiaoxia 649420bd35 feat(viral-video): v1.6 单次Seedance出片+编导分镜脚本 #2124
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m21s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m29s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m0s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m42s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- 输出从营销口播文案改为专业编导分镜脚本(CopyResult v1.6:overview/scene_and_lighting/shots/hard_constraints/negative_prompts/voiceover_script)
- 新 SCRIPT_GENERATION 阶段替代原 copy_fusion+storyboard+review,prompt 指导 LLM 输出严格 JSON 结构
- pipeline 简化为4步:图片分析 → 编导脚本 → TTS整段合成(上传OSS做reference_audios) → 单次Seedance出片 → 上传
- 删除:多段分镜拆分、ffmpeg concat拼接、placeholder占位视频、分段重试降级、BGM单独混音(Seedance generate_audio=true原生合成音效/BGM)
- ai_client/ai_service video_generation 支持 reference_images/reference_audios/reference_videos/generate_audio 参数
- duration 默认15秒,上限30秒;前端时长下拉 5/10/15/20/25/30s
- 首帧图模式不传 ratio(保持 #2110 修复)
- Alembic migration 088 幂等添加 voice_id/voice_source/video_ratio/video_model/copy_result 五列
- _step_image_analysis call_vision() 全路径 None 防护
- 向后兼容:final_copy=voiceover_script、storyboard=shots、老数据 _build_copy_result 降级拼装
- TS types 更新 CopyResult v1.6 结构 + ShotScript/Overview
- 96个 viral 相关单测全通过
2026-10-01 13:55:31 +08:00
xiaoxia 069544da38 fix(viral-video): 三阶段 pipeline 补齐 voice_id/voice_source/video_ratio 字段 (#2123)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API 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 Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 58s
CI/CD Pipeline / Build Staging API Image (push) Successful in 47s
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 / PR Build Worker Image (pull_request) Successful in 1m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 58s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m59s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m12s
CI/CD Pipeline / CI Gate (pull_request) Successful in 12s
CI/CD Pipeline / Validate - Style (push) Successful in 5m23s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m11s
AI Code Review / AI Code Review (pull_request) Successful in 7m0s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m57s
CI/CD Pipeline / Validate - Security (push) Successful in 10m56s
CI/CD Pipeline / Unit Tests (push) Successful in 11m45s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 13m19s
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 API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-01 13:05:38 +08:00
xiaoxia 10007507a5 feat(viral-video): STEP2 分镜脚本UI - 编导分镜卡/约束/负面提示/折叠口播稿 (#2125)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Check push changed paths (push) Successful in 22s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m20s
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 - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 21s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 49s
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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 5m57s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m6s
CI/CD Pipeline / Integration Tests (push) Successful in 4m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m40s
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 / ACR Image Cleanup (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 / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
2026-10-01 12:57:28 +08:00
xiaoxia 19024da223 fix(viral-video): STEP2 UI 重做 - 2列表单/删BGM/文案区重设按钮流 (#2124)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
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 3s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 25s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m56s
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
2026-10-01 12:50:13 +08:00
xiaoxia 033c4a2eab feat(viral-video): 三步独立流程接入真实后端API(替换mock,对接analyze-images/generate-copy/confirm-copy) (#2122)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (pull_request) 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 33s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m53s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 47s
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 4m19s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m23s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m44s
CI/CD Pipeline / Validate - Style (push) Successful in 5m38s
AI Code Review / AI Code Review (pull_request) Successful in 7m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / Validate - Security (push) Successful in 11m34s
CI/CD Pipeline / Unit Tests (push) Successful in 13m22s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
三步独立流程接入真实后端API(PR #2122)
2026-10-01 12:24:48 +08:00
xiaoxia 4ed906e5fa fix(staging): use stable :dev tag for containers to enable Watchtower auto-update (#2121)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging 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 / Check push changed paths (push) Successful in 16s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 20s
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 57s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
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 / Integration Tests (push) Successful in 4m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m2s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
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 / 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 / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-01 12:20:06 +08:00
18 changed files with 2449 additions and 1051 deletions
@@ -0,0 +1,51 @@
"""viral video add copy_result + voice/video columns
Revision ID: 088_viral_video_copy_result
Revises: 087_viral_video_image_analysis
Create Date: 2026-10-01
v1.6 爆款视频字段补齐:
- copy_result JSON: 编导分镜脚本完整结构(overview/scene_and_lighting/shots/hard_constraints/negative_prompts/voiceover_script)
- voice_id/voice_source: TTS 音色参数
- video_ratio/video_model: Seedance 视频比例/模型
注意:线上启动也有幂等 ADD COLUMN 补列逻辑 (_ensure_viral_video_columns),本 migration 提供标准 Alembic 路径,
两套机制互不冲突(IF NOT EXISTS 等价行为)。
"""
import sqlalchemy as sa
from alembic import op
revision = "088_viral_video_copy_result"
down_revision = "087_viral_video_image_analysis"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 幂等添加列(通过单独执行 + 异常忽略兼容已由 backfill 补上的环境)
cols = [
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
("copy_result", "JSON"),
]
conn = op.get_bind()
for name, ddl in cols:
try:
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
except Exception:
# 不支持 IF NOT EXISTS 的库(如老版本 SQLite)直接尝试 ADD COLUMN,失败则忽略
try:
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
except Exception:
pass
def downgrade() -> None:
for name in ("copy_result", "video_model", "video_ratio", "voice_source", "voice_id"):
try:
op.drop_column("viral_video_jobs", name)
except Exception:
pass
+66 -17
View File
@@ -1,6 +1,6 @@
"""爆款视频 API 路由。
v1.5 三步分步流水线端点(前端新交互):
v1.6 三步分步流水线端点(单次 Seedance 出片版):
POST /api/v1/viral-video/analyze-images 阶段1:创建任务 + 仅做图片/视频分析,暂停在 image_analyzed
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 copy_generated
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染,直到完成
@@ -10,7 +10,7 @@ v1.5 三步分步流水线端点(前端新交互):
POST /api/v1/viral-video/{job_id}/confirm-intent 旧的意图确认后继续渲染
通用:
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/storyboard/generated_copy_text)
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/copy_result 编导脚本)
GET /api/v1/viral-video/history 历史记录
POST /api/v1/viral-video/{job_id}/retry 重试失败任务
POST /api/v1/viral-video/{job_id}/analyze-style 触发风格分析
@@ -56,27 +56,55 @@ router = APIRouter()
def _build_copy_result(job) -> dict | None:
"""将后端原始字段拼装为前端期望的 CopyResult 结构(final_copy/suggested_copy/title/scenes)。"""
"""v1.6: 返回编导分镜脚本 CopyResult 结构(给前端/Seedance 使用)。
- 若 job.copy_result 已持久化(v1.6 worker 生成),直接返回(补 final_copy 兜底)。
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表, intent_result)拼装兼容结构。
"""
cr = getattr(job, "copy_result", None)
if isinstance(cr, dict) and cr:
out = dict(cr)
# 向后兼容字段
voiceover = out.get("voiceover_script", "") or ""
out.setdefault("final_copy", voiceover)
out.setdefault("suggested_copy", voiceover)
out.setdefault("title", "")
return out
# 兼容 v1.5 老数据:storyboard 是老格式 [{order,type,description,text,duration,...}]
copy_text = getattr(job, "generated_copy_text", "") or ""
sb = getattr(job, "storyboard", None) or []
intent = getattr(job, "intent_result", None) or {}
if not copy_text and not sb:
return None
scenes = []
title = ""
if isinstance(intent, dict):
title = intent.get("suggested_title") or intent.get("intent", "") or ""
shots = []
for seg in sb:
if isinstance(seg, dict):
scenes.append(
shots.append(
{
"shot": seg.get("description", ""),
"narration": seg.get("text", ""),
"duration": seg.get("duration"),
"time_range": "",
"shot_type_angle_movement": seg.get("ken_burns", ""),
"scene_and_dialogue": (seg.get("text") or "")
+ (" " + seg.get("description", "") if seg.get("description") else ""),
"action_details": "",
"audio_bgm": "",
"transition": seg.get("transition", "硬切"),
"reference_image_index": None,
}
)
ratio = getattr(job, "video_ratio", None) or "9:16"
return {
"title": (intent.get("suggested_title") if isinstance(intent, dict) else None) or "",
"overview": {"theme": title, "total_duration": getattr(job, "duration", 15), "aspect_ratio": ratio},
"scene_and_lighting": "",
"shots": shots,
"hard_constraints": ["无字幕", "无水印", "人物一致性"],
"negative_prompts": ["字幕", "水印", "错误文字", "五官崩坏"],
"voiceover_script": copy_text,
"final_copy": copy_text,
"suggested_copy": copy_text,
"scenes": scenes,
"title": title,
}
@@ -91,7 +119,7 @@ def _to_response(job) -> ViralVideoJobResponse:
viral_structure=job.viral_structure,
marketing_purpose=job.marketing_purpose,
bgm_preference=job.bgm_preference,
duration=job.duration,
duration=job.duration or 15,
user_copy_text=job.user_copy_text,
fusion_level=job.fusion_level,
reference_audio_path=job.reference_audio_path,
@@ -104,6 +132,10 @@ def _to_response(job) -> ViralVideoJobResponse:
storyboard=getattr(job, "storyboard", None),
generated_copy_text=getattr(job, "generated_copy_text", "") or "",
copy_result=_build_copy_result(job),
voice_id=getattr(job, "voice_id", "") or "",
voice_source=getattr(job, "voice_source", "") or "",
video_ratio=getattr(job, "video_ratio", "9:16") or "9:16",
video_model=getattr(job, "video_model", "") or "",
intent_result=job.intent_result,
result_video_url=job.result_video_url,
credits_cost=job.credits_cost,
@@ -148,13 +180,18 @@ def create_viral_video(
viral_structure=request.viral_structure,
marketing_purpose=request.marketing_purpose,
bgm_preference=request.bgm_preference,
duration=request.duration,
duration=request.duration or 15,
user_copy_text=request.user_copy_text,
fusion_level=request.fusion_level,
reference_audio_path=request.reference_audio_path,
reference_video_url=request.reference_video_url,
style_strength=request.style_strength,
style_template_id=request.style_template_id,
voice_id=getattr(request, "voice_id", "") or "",
voice_source=getattr(request, "voice_source", "") or "",
video_ratio=getattr(request, "video_ratio", "9:16") or "9:16",
video_model=getattr(request, "video_model", "") or "",
copy_result=None,
)
# 持久化
@@ -192,6 +229,11 @@ def analyze_images(
reference_video_url=request.reference_video_url or "",
style_template_id=request.style_template_id or "",
style_strength=request.style_strength or "medium",
voice_id=request.voice_id or "",
voice_source=request.voice_source or "",
video_ratio=request.video_ratio or "9:16",
video_model=request.video_model or "",
duration=request.duration or 15,
)
repo.save(job)
@@ -213,10 +255,11 @@ def generate_copy(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.5 阶段2:用户填完营销参数后,跑 意图解析 → 文案融合 → 分镜 → 合规审核。
"""v1.6 阶段2:用户填完营销参数后,跑 意图解析 → 编导分镜脚本生成 → 合规审核。
跑完后状态=copy_generated,响应包含 generated_copy_text + storyboard,
前端展示文案供用户编辑;确认/编辑后调 /{id}/confirm-copy 进入阶段3。
跑完后状态=copy_generated,响应 copy_result(含 overview/scene_and_lighting/shots/
hard_constraints/negative_prompts/voiceover_script),前端展示脚本与口播供用户编辑;
确认/编辑后调 /{id}/confirm-copy 进入阶段3(TTS + 单次 Seedance 出片)。
"""
repo = _get_job_repo(session)
job = repo.get(job_id)
@@ -240,13 +283,19 @@ def generate_copy(
job.marketing_purpose = request.marketing_purpose or job.marketing_purpose
job.bgm_preference = request.bgm_preference or job.bgm_preference
if request.duration:
job.duration = request.duration
job.duration = max(5, min(30, int(request.duration)))
job.user_copy_text = request.user_copy_text if request.user_copy_text else job.user_copy_text
job.fusion_level = request.fusion_level or job.fusion_level
job.reference_audio_path = request.reference_audio_path or job.reference_audio_path
job.reference_video_url = request.reference_video_url or job.reference_video_url
job.style_strength = request.style_strength or job.style_strength
job.style_template_id = request.style_template_id or job.style_template_id
if request.style_guide is not None:
job.style_guide = request.style_guide
job.voice_id = request.voice_id or job.voice_id
job.voice_source = request.voice_source or job.voice_source
job.video_ratio = request.video_ratio or job.video_ratio or "9:16"
job.video_model = request.video_model or job.video_model or ""
job.resume_from_image_analyzed()
repo.update(job)
@@ -269,7 +318,7 @@ def confirm_copy(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.5 阶段3:用户确认/编辑文案后开始 TTS+BGM(skip)+渲染+上传。"""
"""v1.6 阶段3:用户确认/编辑口播后开始 TTS + 单次 Seedance 生成 + 上传。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
+110 -55
View File
@@ -1,4 +1,4 @@
"""爆款视频 API schemas。"""
"""爆款视频 API schemas (v1.6 单次 Seedance 出片版)。"""
from __future__ import annotations
@@ -14,41 +14,84 @@ VALID_STAGES = (
"image_analysis",
"video_analysis",
"intent_parsing",
"copy_fusion",
"storyboard",
"script_generation",
"review",
"tts",
"bgm_select",
"rendering",
"musetalk",
"uploading",
)
VALID_VIDEO_RATIOS = ("9:16", "16:9", "1:1", "4:3", "3:4", "21:9")
VALID_DURATIONS = (5, 10, 15, 20, 25, 30)
# -- 编导脚本结构(v1.6) --
class ShotScript(BaseModel):
"""逐镜头分镜。"""
time_range: str = Field(default="", description="时间区间,如 0-3秒")
shot_type_angle_movement: str = Field(default="", description="景别/角度/运镜,如『近景俯拍45度,缓慢推镜』")
scene_and_dialogue: str = Field(default="", description="场景描述+口播台词")
action_details: str = Field(default="", description="人物动作、表情、物品操作细节")
audio_bgm: str = Field(default="", description="环境音+BGM提示")
transition: str = Field(default="硬切", description="转场方式:硬切/淡入淡出/叠化")
reference_image_index: int | None = Field(
default=None, description="参考图片索引(0-based,对应上传的第几张产品图)"
)
class CopyResultOverview(BaseModel):
theme: str = ""
total_duration: int = 15
aspect_ratio: str = "9:16"
class CopyResult(BaseModel):
"""v1.6 编导分镜脚本结构(给前端 + Seedance 用)。"""
overview: CopyResultOverview = Field(default_factory=CopyResultOverview)
scene_and_lighting: str = ""
shots: list[ShotScript] = Field(default_factory=list)
hard_constraints: list[str] = Field(default_factory=list)
negative_prompts: list[str] = Field(default_factory=list)
voiceover_script: str = Field(
default="", description="纯口播对白,从各镜 scene_and_dialogue 的对白部分拼接,供 TTS 使用"
)
# 向后兼容:final_copy = voiceover_script
final_copy: str = ""
suggested_copy: str = ""
title: str = ""
# -- Request Schemas --
class CreateViralVideoRequest(BaseModel):
"""创建爆款视频任务请求(旧接口:一键跑完前半段到 WAIT_USER_CONFIRM,保留兼容)。"""
"""旧接口:一键创建(保留兼容)。"""
images: list[str] = Field(..., min_length=1, max_length=20, description="产品图片 URL 列表")
industry: str = Field(default="", description="行业")
target_customer: str = Field(default="", description="目标客户描述")
persona_id: str = Field(default="", description="人设 ID")
viral_structure: str = Field(default="", description="爆款结构类型")
marketing_purpose: str = Field(default="", description="营销目的")
bgm_preference: str = Field(default="", description="BGM 偏好")
duration: int = Field(default=30, ge=5, le=180, description="视频时长(秒)")
user_copy_text: str = Field(default="", description="用户原始文案(我说你写)")
fusion_level: str = Field(default="ai_polish", description="文案融合级别: ai_full/ai_polish/user_primary")
reference_audio_path: str = Field(default="", description="参考音频路径")
reference_video_url: str = Field(default="", description="参考爆款视频 URL")
style_strength: str = Field(default="medium", description="风格强度: light/medium/strict")
style_template_id: str = Field(default="", description="风格模板 ID")
images: list[str] = Field(..., min_length=1, max_length=20)
industry: str = ""
target_customer: str = ""
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
bgm_preference: str = ""
duration: int = Field(default=15, ge=5, le=30, description="视频时长(秒),5-30")
user_copy_text: str = ""
fusion_level: str = "ai_polish"
reference_audio_path: str = ""
reference_video_url: str = ""
style_strength: str = "medium"
style_template_id: str = ""
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: str = ""
@field_validator("fusion_level")
@classmethod
def _validate_fusion_level(cls, v: str) -> str:
def _v_fl(cls, v: str) -> str:
if v == "full_ai":
return "ai_full"
if v not in VALID_FUSION_LEVELS:
@@ -57,37 +100,47 @@ class CreateViralVideoRequest(BaseModel):
@field_validator("style_strength")
@classmethod
def _validate_style_strength(cls, v: str) -> str:
def _v_ss(cls, v: str) -> str:
if v not in VALID_STYLE_STRENGTHS:
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
return v
class AnalyzeImagesRequest(BaseModel):
"""v1.5 阶段1:创建任务并仅做图片/视频分析。images 必填,其他参数可选(阶段2再传)。"""
"""v1.5+ 阶段1:创建任务 + 图片/视频分析。"""
images: list[str] = Field(..., min_length=1, max_length=20)
reference_video_url: str = Field(default="", description="参考爆款视频 URL(可选,有则同步做风格分析)")
style_template_id: str = Field(default="", description="风格模板 ID(可选)")
style_strength: str = Field(default="medium")
images: list[str] = Field(..., min_length=1, max_length=30)
reference_video_url: str = ""
style_template_id: str = ""
style_strength: str = "medium"
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: str = ""
duration: int = Field(default=15, ge=5, le=30)
class GenerateCopyRequest(BaseModel):
"""v1.5 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 COPY_GENERATED。"""
"""v1.5+ 阶段2:填完营销参数,生成编导脚本。"""
industry: str = Field(default="")
target_customer: str = Field(default="")
persona_id: str = Field(default="")
viral_structure: str = Field(default="")
marketing_purpose: str = Field(default="")
bgm_preference: str = Field(default="")
duration: int = Field(default=30, ge=5, le=180)
user_copy_text: str = Field(default="")
fusion_level: str = Field(default="ai_polish")
reference_audio_path: str = Field(default="")
reference_video_url: str = Field(default="")
style_strength: str = Field(default="medium")
style_template_id: str = Field(default="")
industry: str = ""
target_customer: str = ""
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
bgm_preference: str = ""
duration: int = Field(default=15, ge=5, le=30)
user_copy_text: str = ""
fusion_level: str = "ai_polish"
reference_audio_path: str = ""
reference_video_url: str = ""
style_strength: str = "medium"
style_template_id: str = ""
style_guide: dict | None = None
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: str = ""
@field_validator("fusion_level")
@classmethod
@@ -107,30 +160,28 @@ class GenerateCopyRequest(BaseModel):
class ConfirmCopyRequest(BaseModel):
"""v1.5 阶段3:用户确认/编辑文案后开始渲染。"""
"""v1.5+ 阶段3:用户确认/编辑口播后开始渲染(TTS+单次Seedance)。"""
edited_copy: str = Field(default="", description="用户编辑后的最终文案;为空则使用 AI 生成文案")
edited_copy: str = Field(default="", description="用户编辑后的口播文案;为空则用 AI 生成的 voiceover_script")
class ConfirmIntentRequest(BaseModel):
"""确认意图请求(旧 confirm-intent,兼容)。"""
"""旧 confirm-intent(兼容)。"""
confirmed_copy: str = Field(default="", description="用户确认/修改后的文案")
adjustments: str = Field(default="", description="用户对 AI 文案的调整意见")
confirmed_copy: str = ""
adjustments: str = ""
class AnalyzeStyleRequest(BaseModel):
"""触发参考视频风格分析请求。"""
reference_video_url: str = Field(..., description="参考视频 URL")
style_template_id: str = Field(default="", description="风格模板 ID(可选覆盖)")
style_template_id: str = ""
# -- Response Schemas --
class ViralVideoJobResponse(BaseModel):
"""爆款视频任务响应。v1.5 新增 image_analysis/storyboard/generated_copy_text 字段。"""
"""爆款视频任务响应(v1.6 包含 copy_result 编导脚本结构)。"""
id: str
user_id: str
@@ -141,7 +192,7 @@ class ViralVideoJobResponse(BaseModel):
viral_structure: str = ""
marketing_purpose: str = ""
bgm_preference: str = ""
duration: int = 30
duration: int = 15
user_copy_text: str = ""
fusion_level: str = "ai_polish"
reference_audio_path: str = ""
@@ -150,13 +201,17 @@ class ViralVideoJobResponse(BaseModel):
style_guide: dict | None = None
style_template_id: str = ""
status: str
# v1.4 VLM 结果
image_analysis: dict | None = None
# v1.5 三步分步产物(原始字段,保留给后端/老调用方)
# v1.6 编导脚本(推荐前端使用)
copy_result: dict | None = None
# v1.5 兼容字段
storyboard: list | None = None
generated_copy_text: str = ""
# v1.5 前端 CopyResult 结构(final_copy/suggested_copy/title/scenes)
copy_result: dict | None = None
# 音色/视频参数
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: str = ""
intent_result: dict | None = None
result_video_url: str = ""
credits_cost: int = 0
+114 -36
View File
@@ -12,6 +12,14 @@ export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [
{ value: "strict", label: "像素级复刻" },
]
/** v1.6 前端时长下拉选项(5/10/15/20/25/30秒) */
export const VALID_DURATIONS = [5, 10, 15, 20, 25, 30] as const
export type VideoDuration = (typeof VALID_DURATIONS)[number]
/** v1.6 支持的画幅比例 */
export const VALID_RATIOS = ["9:16", "16:9", "1:1"] as const
export type VideoRatio = (typeof VALID_RATIOS)[number]
export type ViralVideoStatus =
| "pending"
| "running"
@@ -23,39 +31,25 @@ export type ViralVideoStatus =
| "cancelled"
/**
* 后端流水线阶段字符串。前端不展示逐阶段进度列表,仅保留类型
* 用于轮询时判断当前在哪个大阶段(分析中 vs 文案生成 vs 视频生成)。
* v1.6 后端流水线阶段。单次 Seedance 出片版:
* image_analysis → video_analysis(可选) → intent_parsing → script_generation → review → tts → rendering → uploading
*/
export type ViralVideoStage =
| "image_analysis"
| "video_analysis"
| "intent_parsing"
| "copy_fusion"
| "storyboard"
| "script_generation"
| "review"
| "tts"
| "bgm_select"
| "rendering"
| "musetalk"
| "uploading"
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
/** 文案相关阶段:属于「生成文案」按钮的范围 */
const COPY_STAGES = new Set<ViralVideoStage>([
"intent_parsing",
"copy_fusion",
"storyboard",
"review",
])
/** 视频相关阶段:属于「开始生成视频」按钮的范围 */
const VIDEO_STAGES = new Set<ViralVideoStage>([
"tts",
"bgm_select",
"rendering",
"musetalk",
"uploading",
])
/** 编导脚本阶段:属于「生成文案」按钮的范围 */
const COPY_STAGES = new Set<ViralVideoStage>(["intent_parsing", "script_generation", "review"])
/** 视频生成阶段:属于「开始生成视频」按钮的范围(v1.6: TTS+单次Seedance+上传) */
const VIDEO_STAGES = new Set<ViralVideoStage>(["tts", "rendering", "uploading"])
export function isImageAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && IMAGE_ANALYSIS_STAGES.has(stage)
@@ -66,7 +60,7 @@ export function isCopyStage(stage: ViralVideoStage | undefined): boolean {
export function isVideoStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && VIDEO_STAGES.has(stage)
}
/** 兼容旧调用:旧的 isAnalysisStage 视为「图片分析+文案」的所有前置阶段 */
/** 兼容旧调用:分析图片+生成文案 的所有前置阶段 */
export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return isImageAnalysisStage(stage) || isCopyStage(stage)
}
@@ -74,12 +68,20 @@ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
/** 单张图片 VLM 识别出的商品信息 */
export interface ImageProductAnalysis {
name?: string
spec?: string
category?: string
brand?: string
colors?: string[]
material_or_texture?: string
key_features?: string[]
visual_style?: string
scene?: string
target_audience_hint?: string
text_on_image?: string
/** 旧字段兼容 */
spec?: string
features?: string[] | string
label_text?: string
selling_points?: string[]
scene?: string
selling_points?: string
image_index?: number
}
@@ -87,10 +89,50 @@ export interface ImageAnalysisResult {
products?: ImageProductAnalysis[]
}
/** v1.6 编导分镜脚本 - 单镜头 */
export interface ShotScript {
/** 时间区间,如 "0-3秒" */
time_range?: string
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
shot_type_angle_movement?: string
/** 场景描述+对白 */
scene_and_dialogue?: string
/** 人物动作/表情/物品操作细节 */
action_details?: string
/** 环境音+BGM提示 */
audio_bgm?: string
/** 转场方式(硬切/淡入淡出/叠化/结束) */
transition?: string
/** 参考图片索引(0-based,对应上传产品图数组) */
reference_image_index?: number | null
}
/** v1.6 编导分镜脚本 - 总览 */
export interface CopyResultOverview {
theme?: string
total_duration?: number
aspect_ratio?: string
}
/** v1.6 编导分镜脚本(核心输出结构,给 Seedance 做 prompt,给 TTS 取 voiceover_script) */
export interface CopyResult {
overview?: CopyResultOverview
/** 整体场景+光线描述 */
scene_and_lighting?: string
/** 逐镜头时间轴 */
shots?: ShotScript[]
/** 硬性约束(禁止字幕/水印/变形等) */
hard_constraints?: string[]
/** 负面提示词 */
negative_prompts?: string[]
/** 完整口播稿(纯文本,用于 TTS 合成) */
voiceover_script?: string
/** 向后兼容:= voiceover_script */
final_copy?: string
/** 向后兼容:= voiceover_script */
suggested_copy?: string
title?: string
/** v1.5 旧字段兼容(老数据降级时可能出现) */
scenes?: Array<{ shot: string; narration: string; duration?: number }>
}
@@ -104,13 +146,18 @@ export interface StyleTemplate {
}
export interface IntentResult {
product: string
selling_points: string[]
target_audience: string
tone: string
structure: string
duration: number
intent?: string
key_messages?: string[]
tone?: string
target_emotion?: string
call_to_action?: string
suggested_title?: string
/** v1.5 旧字段兼容 */
product?: string
selling_points?: string[]
target_audience?: string
structure?: string
duration?: number
suggested_copy?: string
}
@@ -123,7 +170,9 @@ export interface ViralVideoJob {
style_template_id?: string
style_guide?: string | Record<string, unknown>
user_copy_text?: string
/** v1.6: = copy_result.voiceover_script(从 copy_result 派生,向后兼容) */
final_copy_text?: string
generated_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_mode?: "global" | "per_video"
@@ -131,8 +180,17 @@ export interface ViralVideoJob {
bgm_preference?: string
intent_result?: IntentResult
intent_text?: string
/** v1.6 编导分镜脚本(核心产物) */
copy_result?: CopyResult
/** 向后兼容:= copy_result.shots */
storyboard?: ShotScript[]
image_analysis?: ImageAnalysisResult
/** 视频比例:9:16 / 16:9 / 1:1,默认 9:16 */
video_ratio?: string
/** Seedance 模型 ID(空=后端默认) */
video_model?: string
/** 视频时长(秒,5-30,默认15) */
duration?: number
progress_stage?: ViralVideoStage
progress_percent?: number
progress_message?: string
@@ -162,6 +220,7 @@ export interface GenerateViralVideoRequest {
persona_id?: string
viral_structure?: string
marketing_purpose?: string
/** 视频时长(5-30秒,默认15) */
duration?: number
video_model?: string
video_ratio?: string
@@ -176,15 +235,25 @@ export interface HistoryResponse {
page_size: number
}
/** v1.5 阶段1请求:仅做图片/视频分析(POST /viral-video/analyze-images) */
/** v1.6 阶段1请求:图片/视频分析(POST /viral-video/analyze-images) */
export interface AnalyzeImagesRequest {
images: string[]
reference_video_url?: string
style_template_id?: string
style_strength?: StyleStrength
/** TTS 音色 ID(STEP1 已选音色时传) */
voice_id?: string
/** 音色来源:preset | library | clone | upload */
voice_source?: "preset" | "library" | "clone" | "upload"
/** Seedance 视频比例:9:16 | 16:9 | 1:1 */
video_ratio?: string
/** Seedance 模型 ID(空则使用服务端默认) */
video_model?: string
/** 视频时长(秒,5-30,默认15) */
duration?: number
}
/** v1.5 阶段2请求:填完营销参数后生成文案+分镜(POST /viral-video/{id}/generate-copy) */
/** v1.6 阶段2请求:填完营销参数后生成编导分镜脚本(POST /viral-video/{id}/generate-copy) */
export interface GenerateCopyRequest {
industry?: string
target_customer?: string
@@ -192,6 +261,7 @@ export interface GenerateCopyRequest {
viral_structure?: string
marketing_purpose?: string
bgm_preference?: string
/** 视频时长(秒,5-30,默认15) */
duration?: number
user_copy_text?: string
fusion_level?: FusionLevel
@@ -200,15 +270,23 @@ export interface GenerateCopyRequest {
style_strength?: StyleStrength
style_template_id?: string
style_guide?: string | Record<string, unknown>
/** TTS 音色 ID(优先级高于 persona_id) */
voice_id?: string
/** 音色来源:preset | library | clone | upload */
voice_source?: "preset" | "library" | "clone" | "upload"
/** Seedance 视频比例(9:16/16:9/1:1 等) */
video_ratio?: string
/** Seedance 模型 ID(空则使用服务端默认) */
video_model?: string
}
/** v1.5 阶段3请求:用户确认/编辑文案后开始渲染(POST /viral-video/{id}/confirm-copy) */
/** v1.6 阶段3请求:用户确认/编辑口播文案后开始单次 Seedance 出片(POST /viral-video/{id}/confirm-copy) */
export interface ConfirmCopyRequest {
/** 用户编辑后的最终文案;为空则使用 AI 生成文案 */
/** 用户编辑后的口播文案;为空则使用 AI 生成的 voiceover_script */
edited_copy?: string
}
/** 分镜片段结构(后端 storyboard 字段的元素形态,保留供调试/进阶使用;主流程请使用 copy_result.scenes) */
/** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */
export interface StoryboardSegment {
order: number
type: string
@@ -1596,3 +1596,370 @@
grid-template-columns: repeat(2, 1fr);
}
}
/* ── STEP2 2列表单网格 ── */
.vv-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 18px;
margin-top: 12px;
}
.vv-form-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.vv-label {
font-size: 13px;
font-weight: 500;
color: #374151;
display: flex;
align-items: center;
gap: 4px;
}
.vv-label-optional {
font-size: 12px;
color: #9ca3af;
font-weight: 400;
}
.vv-input {
width: 100%;
height: 36px;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #fff;
padding: 0 12px;
font-size: 13px;
color: #111827;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.vv-input:focus {
border-color: #7c3aed;
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.15);
}
.vv-input:disabled {
background: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
.vv-input::placeholder {
color: #9ca3af;
}
/* 自定义 Select 样式(浅色紫调) */
.vv-select-purple .ant-select-selector {
border-radius: 8px !important;
border: 1px solid #e5e7eb !important;
background: #fff !important;
min-height: 36px !important;
padding: 0 28px 0 12px !important;
box-shadow: none !important;
transition:
border-color 0.15s,
box-shadow 0.15s !important;
}
.vv-select-purple.ant-select-focused .ant-select-selector,
.vv-select-purple .ant-select-focused .ant-select-selector {
border-color: #7c3aed !important;
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.15) !important;
}
.vv-select-purple .ant-select-selection-item,
.vv-select-purple .ant-select-selection-placeholder {
line-height: 34px !important;
font-size: 13px !important;
color: #111827 !important;
}
.vv-select-purple .ant-select-selection-placeholder {
color: #9ca3af !important;
}
.vv-select-purple .ant-select-arrow {
color: #6b7280;
right: 10px !important;
}
.vv-select-purple.ant-select-disabled .ant-select-selector {
background: #f9fafb !important;
color: #9ca3af !important;
cursor: not-allowed;
}
/* ghost/outline 按钮 */
.vv-btn-ghost {
background: #fff;
color: #7c3aed;
border: 1px solid #d6c6fb;
}
.vv-btn-ghost:hover {
background: #f8f4ff;
border-color: #7c3aed;
}
@media (max-width: 640px) {
.vv-form-grid {
grid-template-columns: 1fr;
}
}
/* ── 分镜脚本(Storyboard)样式 ── */
.vv-storyboard {
display: flex;
flex-direction: column;
gap: 14px;
}
.vv-copy-tag {
display: inline-flex;
align-items: center;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
}
.vv-copy-tag-preview {
background: #fef3c7;
color: #92400e;
}
/* 总览卡片 */
.vv-sb-overview {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
padding: 12px;
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
border-radius: 10px;
border: 1px solid #e9e4ff;
}
.vv-sb-ov-field label {
display: block;
font-size: 11px;
color: #6b7280;
margin-bottom: 4px;
}
.vv-sb-input {
height: 32px;
font-size: 13px;
}
/* 分块标题 */
.vv-sb-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.vv-sb-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #374151;
}
/* 镜头卡片 */
.vv-sb-shots {
display: flex;
flex-direction: column;
gap: 10px;
}
.vv-sb-shot {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 12px;
}
.vv-sb-shot-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.vv-sb-shot-num {
background: #7c3aed;
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
}
.vv-sb-time {
height: 28px;
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 6px;
padding: 0 10px;
font-size: 12px;
color: #6b7280;
width: 100px;
outline: none;
}
.vv-sb-time:focus {
border-color: #7c3aed;
}
.vv-sb-shot-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
}
.vv-sb-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.vv-sb-cell-wide {
grid-column: 1 / -1;
}
.vv-sb-cell label {
font-size: 11px;
color: #6b7280;
font-weight: 500;
}
.vv-sb-cell-ta {
min-height: 54px !important;
font-size: 12.5px !important;
padding: 7px 10px !important;
line-height: 1.55 !important;
resize: vertical;
}
.vv-sb-ref-box {
width: 80px;
height: 54px;
border-radius: 8px;
overflow: hidden;
background: #fff;
border: 1px dashed #d1d5db;
display: flex;
align-items: center;
justify-content: center;
}
.vv-sb-ref-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.vv-sb-ref-empty {
font-size: 11px;
color: #9ca3af;
}
/* 标签(硬性约束/负面提示词) */
.vv-sb-taglist {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.vv-sb-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 10px;
border-radius: 14px;
font-size: 12px;
}
.vv-sb-tag-hard {
background: #f3f0ff;
color: #6d28d9;
border: 1px solid #ddd6fe;
}
.vv-sb-tag-neg {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
}
.vv-sb-tag-input {
background: transparent;
border: none;
outline: none;
font-size: 12px;
color: inherit;
min-width: 80px;
padding: 2px 0;
}
.vv-sb-tag-hard .vv-sb-tag-input {
color: #6d28d9;
}
.vv-sb-tag-neg .vv-sb-tag-input {
color: #b91c1c;
}
.vv-sb-tag-del {
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.08);
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
}
.vv-sb-tag-del:hover {
background: rgba(0, 0, 0, 0.18);
}
.vv-sb-tag-add {
border: 1px dashed #d1d5db;
background: transparent;
color: #6b7280;
font-size: 12px;
padding: 3px 10px;
border-radius: 14px;
cursor: pointer;
}
.vv-sb-tag-add:hover {
border-color: #7c3aed;
color: #7c3aed;
}
/* 口播稿折叠 */
.vv-sb-collapse {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 14px;
cursor: pointer;
font-size: 13px;
color: #374151;
font-weight: 500;
transition:
border-color 0.15s,
background 0.15s;
}
.vv-sb-collapse:hover {
border-color: #7c3aed;
background: #faf8ff;
}
.vv-sb-caret {
transition: transform 0.2s;
color: #9ca3af;
font-size: 12px;
}
.vv-sb-caret.open {
transform: rotate(180deg);
color: #7c3aed;
}
.vv-sb-vo {
min-height: 100px !important;
font-size: 13px !important;
line-height: 1.7 !important;
}
@media (max-width: 640px) {
.vv-sb-overview {
grid-template-columns: 1fr;
}
.vv-sb-shot-grid {
grid-template-columns: 1fr;
}
.vv-sb-cell-wide {
grid-column: auto;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37
View File
@@ -104,6 +104,43 @@ Staging 当前可以保持 no-op;Production 开启前必须先验证 SMTP/Redi
---
## Staging 服务器 Docker 凭证配置
Staging 服务器(116.62.226.203)需要配置 ACR 和 Gitea Registry 凭证,否则 docker pull 和 Watchtower 自动更新会失败。
### 凭证文件位置
- Docker 配置文件:`/root/.docker/config.json`
- 包含两个 registry 的认证信息:
- `xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com`(阿里云 ACR)
- `git.xiaoxiajianji.com`(Gitea 容器镜像仓库)
### 服务器迁移后恢复步骤
```bash
# 1. 登录 ACR
docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u <ACR_USERNAME>
# 2. 登录 Gitea Registry
docker login git.xiaoxiajianji.com -u xiaoxia -p <GITEA_REGISTRY_TOKEN>
# 3. 重启 Watchtower(确保挂载最新 config.json)
docker restart watchtower
```
### Watchtower 配置
- 容器名:`watchtower`
- 检查间隔:300 秒(5 分钟)
- 监控容器:`xiaoxia-api-staging`、`xiaoxia-worker-staging`、`xiaoxia-web-staging`
- 必须挂载 `-v /root/.docker/config.json:/config.json` 才能拉取私有镜像
- 必须挂载 `-v /var/run/docker.sock:/var/run/docker.sock` 才能管理容器
- 容器使用 `:dev` 稳定 tag,Watchtower 通过检测 `:dev` tag 的 digest 变化来发现更新
### 镜像 Tag 策略
- CI 每次构建推送三种 tag:`${GITHUB_SHA}`(精确版本)、`${GITHUB_REF_NAME}`(分支名)、`:dev`(滚动 tag,仅 develop 分支)
- Staging 容器统一使用 `:dev` tag 启动,确保 Watchtower 能自动发现新版本
- Migration(alembic)使用 commit SHA tag 执行,不依赖 Watchtower
---
## Gitea Actions 约定
- `develop` 分支触发 staging 部署。
@@ -943,12 +943,20 @@ class ViralVideoJobModel(Base):
style_strength = Column(String(20), nullable=False, default="medium")
style_guide = Column(JSON, nullable=True)
style_template_id = Column(String(36), nullable=False, default="", index=True)
# v1.5 音频/视频参数
voice_id = Column(String(200), nullable=False, default="")
voice_source = Column(String(20), nullable=False, default="")
video_ratio = Column(String(10), nullable=False, default="9:16")
video_model = Column(String(100), nullable=False, default="")
# 结果与状态
status = Column(String(30), nullable=False, default="pending", index=True)
intent_result = Column(JSON, nullable=True)
image_analysis = Column(JSON, nullable=True)
storyboard = Column(JSON, nullable=True)
generated_copy_text = Column(Text, nullable=False, default="")
copy_result = Column(
JSON, nullable=True
) # v1.6: 编导脚本结构{overview,scene_and_lighting,shots,hard_constraints,negative_prompts,voiceover_script}
result_video_url = Column(String(1000), nullable=False, default="")
credits_cost = Column(Integer, nullable=False, default=0)
error_msg = Column(Text, nullable=False, default="")
@@ -84,6 +84,11 @@ def ensure_database_exists(database_url: str) -> None:
_VIRAL_VIDEO_BACKFILL_COLS = [
("storyboard", "JSON"),
("generated_copy_text", "TEXT NOT NULL DEFAULT ''"),
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
("copy_result", "JSON"),
]
@@ -24,7 +24,7 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
viral_structure=model.viral_structure or "",
marketing_purpose=model.marketing_purpose or "",
bgm_preference=model.bgm_preference or "",
duration=model.duration or 30,
duration=model.duration or 15,
user_copy_text=model.user_copy_text or "",
fusion_level=model.fusion_level or "ai_polish",
reference_audio_path=model.reference_audio_path or "",
@@ -32,11 +32,16 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
style_strength=getattr(model, "style_strength", "medium") or "medium",
style_guide=dict(model.style_guide) if model.style_guide else None,
style_template_id=getattr(model, "style_template_id", "") or "",
voice_id=getattr(model, "voice_id", "") or "",
voice_source=getattr(model, "voice_source", "") or "",
video_ratio=getattr(model, "video_ratio", "9:16") or "9:16",
video_model=getattr(model, "video_model", "") or "",
status=ViralVideoStatus(model.status) if model.status else ViralVideoStatus.PENDING,
intent_result=dict(model.intent_result) if model.intent_result else None,
image_analysis=dict(model.image_analysis) if getattr(model, "image_analysis", None) else None,
storyboard=list(model.storyboard) if getattr(model, "storyboard", None) else None,
generated_copy_text=getattr(model, "generated_copy_text", "") or "",
copy_result=dict(model.copy_result) if getattr(model, "copy_result", None) else None,
result_video_url=model.result_video_url or "",
credits_cost=model.credits_cost or 0,
error_msg=model.error_msg or "",
@@ -73,11 +78,16 @@ class SQLAlchemyViralVideoJobRepository:
style_strength=job.style_strength,
style_guide=job.style_guide,
style_template_id=job.style_template_id,
voice_id=job.voice_id,
voice_source=job.voice_source,
video_ratio=job.video_ratio,
video_model=job.video_model,
status=job.status,
intent_result=job.intent_result,
image_analysis=job.image_analysis,
storyboard=job.storyboard,
generated_copy_text=job.generated_copy_text,
copy_result=job.copy_result,
result_video_url=job.result_video_url,
credits_cost=job.credits_cost,
error_msg=job.error_msg,
@@ -100,6 +110,7 @@ class SQLAlchemyViralVideoJobRepository:
model.image_analysis = job.image_analysis
model.storyboard = job.storyboard
model.generated_copy_text = job.generated_copy_text or ""
model.copy_result = job.copy_result
model.result_video_url = job.result_video_url
model.credits_cost = job.credits_cost
model.error_msg = job.error_msg
@@ -121,6 +132,10 @@ class SQLAlchemyViralVideoJobRepository:
model.reference_video_url = job.reference_video_url
model.style_strength = job.style_strength
model.style_template_id = job.style_template_id
model.voice_id = job.voice_id or ""
model.voice_source = job.voice_source or ""
model.video_ratio = job.video_ratio or "9:16"
model.video_model = job.video_model or ""
model.updated_at = datetime.now(timezone.utc)
self.session.commit()
+37 -44
View File
@@ -1,6 +1,7 @@
"""ViralVideoJob 领域模型 — 爆款视频任务.
状态机(v1.5 三步分步):
v1.6 重大简化:Seedance 2.5 单次最长30秒,单次调用直接出片,不再分段/拼接/ffmpeg concat。
状态机(三步分步):
pending -> running -> image_analyzed -> running -> copy_generated -> running -> completed
wait_user_confirm -> running -> completed (旧路径兼容)
任意阶段 fail; 任意非终态 cancel.
@@ -26,8 +27,6 @@ from uuid import uuid4
class ViralVideoStatus(StrEnum):
"""爆款视频任务状态枚举。"""
PENDING = "pending"
RUNNING = "running"
IMAGE_ANALYZED = "image_analyzed"
@@ -39,44 +38,32 @@ class ViralVideoStatus(StrEnum):
class ViralVideoStage(StrEnum):
"""编排流水线阶段枚举(用于 WS 进度推送)。"""
IMAGE_ANALYSIS = "image_analysis"
VIDEO_ANALYSIS = "video_analysis"
INTENT_PARSING = "intent_parsing"
COPY_FUSION = "copy_fusion"
STORYBOARD = "storyboard"
SCRIPT_GENERATION = "script_generation" # v1.6: 编导分镜脚本(融合原 copy_fusion+storyboard+review)
REVIEW = "review"
TTS = "tts"
BGM_SELECT = "bgm_select"
RENDERING = "rendering"
MUSETALK = "musetalk"
RENDERING = "rendering" # v1.6: 单次 Seedance 生成(BGM/音效/画面一次出片)
UPLOADING = "uploading"
class FusionLevel(StrEnum):
"""文案融合级别。"""
AI_FULL = "ai_full"
AI_POLISH = "ai_polish"
USER_PRIMARY = "user_primary"
class StyleStrength(StrEnum):
"""风格强度。"""
LIGHT = "light"
MEDIUM = "medium"
STRICT = "strict"
class PromptType(StrEnum):
"""Prompt 模板类型(与 #2040 seed 对齐)。"""
IMAGE_ANALYSIS = "image_analysis"
INTENT_PARSING = "intent_parsing"
COPY_FUSION = "copy_fusion"
STORYBOARD = "storyboard"
SCRIPT_GENERATION = "script_generation"
REVIEW = "review"
VIDEO_STYLE_INTEGRATION = "video_style_integration"
STYLE_CONSTRAINT = "style_constraint"
@@ -88,20 +75,17 @@ STAGE_LABELS = {
ViralVideoStage.IMAGE_ANALYSIS: "图片分析",
ViralVideoStage.VIDEO_ANALYSIS: "视频风格分析",
ViralVideoStage.INTENT_PARSING: "意图解析",
ViralVideoStage.COPY_FUSION: "文案融合",
ViralVideoStage.STORYBOARD: "分镜脚本",
ViralVideoStage.SCRIPT_GENERATION: "编导脚本生成",
ViralVideoStage.REVIEW: "合规审核",
ViralVideoStage.TTS: "AI 配音",
ViralVideoStage.BGM_SELECT: "BGM 选择",
ViralVideoStage.RENDERING: "视频渲染",
ViralVideoStage.MUSETALK: "数字人口型",
ViralVideoStage.RENDERING: "视频生成",
ViralVideoStage.UPLOADING: "上传发布",
}
@dataclass
class ViralVideoJob:
"""爆款视频任务领域实体。"""
"""爆款视频任务领域实体(v1.6 单次 Seedance 出片版)。"""
user_id: str
images: list[str] = field(default_factory=list)
@@ -111,24 +95,28 @@ class ViralVideoJob:
viral_structure: str = ""
marketing_purpose: str = ""
bgm_preference: str = ""
duration: int = 30
duration: int = 15 # v1.6: 默认15秒,上限30秒(Seedance 2.5 单次最大30s)
user_copy_text: str = ""
fusion_level: str = FusionLevel.AI_POLISH
reference_audio_path: str = ""
# v1.3
reference_video_url: str = ""
style_strength: str = StyleStrength.MEDIUM
style_guide: dict | None = None
style_template_id: str = ""
# v1.4 图片分析结果(run_pipeline 持久化,resume 时读取给文案/分镜)
# v1.5.1 音频/视频参数
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: str = ""
# v1.4+ 产物
image_analysis: dict | None = None
# v1.5 三步分步流水线产物(持久化,供前端 GET 读取 + resume 消费)
storyboard: list | None = None
generated_copy_text: str = ""
intent_result: dict | None = None
generated_copy_text: str = "" # v1.6: 存 voiceover_script(纯口播对白),字段名兼容
storyboard: list | None = None # v1.6: 存 copy_result.shots,字段名兼容
copy_result: dict | None = None # v1.6: 完整编导脚本结构
# 状态
id: str = field(default_factory=lambda: uuid4().hex)
status: ViralVideoStatus = ViralVideoStatus.PENDING
intent_result: dict | None = None
result_video_url: str = ""
credits_cost: int = 0
error_msg: str = ""
@@ -155,7 +143,6 @@ class ViralVideoJob:
self.updated_at = datetime.now(timezone.utc)
def mark_image_analyzed(self) -> None:
"""阶段1完成:图片/视频分析完成,等待用户填参数或直接触发阶段2。"""
if self.status not in (ViralVideoStatus.PENDING, ViralVideoStatus.RUNNING):
raise ValueError(f"Cannot transition from {self.status} to image_analyzed")
self.status = ViralVideoStatus.IMAGE_ANALYZED
@@ -163,8 +150,8 @@ class ViralVideoJob:
self.started_at = datetime.now(timezone.utc)
self.updated_at = datetime.now(timezone.utc)
def mark_copy_generated(self, copy_text: str, storyboard: list) -> None:
"""阶段2完成:文案+分镜+审核完成,等待用户编辑后确认。"""
def mark_copy_generated(self, copy_result: dict) -> None:
"""v1.6 阶段2完成:编导脚本(含 voiceover_script/shots/硬约束/负面词)已生成。"""
if self.status not in (
ViralVideoStatus.IMAGE_ANALYZED,
ViralVideoStatus.RUNNING,
@@ -172,12 +159,14 @@ class ViralVideoJob:
):
raise ValueError(f"Cannot transition from {self.status} to copy_generated")
self.status = ViralVideoStatus.COPY_GENERATED
self.generated_copy_text = copy_text or ""
self.storyboard = list(storyboard) if storyboard else []
self.copy_result = copy_result or {}
if isinstance(copy_result, dict):
self.generated_copy_text = copy_result.get("voiceover_script", "") or ""
shots = copy_result.get("shots") or []
self.storyboard = list(shots) if isinstance(shots, list) else []
self.updated_at = datetime.now(timezone.utc)
def mark_wait_user_confirm(self, intent_result: dict) -> None:
"""旧流水线兼容:意图解析完成等待用户确认(老接口)。"""
if self.status != ViralVideoStatus.RUNNING:
raise ValueError(f"Cannot transition from {self.status} to wait_user_confirm")
self.status = ViralVideoStatus.WAIT_USER_CONFIRM
@@ -185,7 +174,6 @@ class ViralVideoJob:
self.updated_at = datetime.now(timezone.utc)
def resume_from_image_analyzed(self, **kwargs) -> None:
"""阶段1->阶段2:用户已填参数,开始跑文案/分镜。kwargs 覆盖参数字段。"""
if self.status not in (ViralVideoStatus.IMAGE_ANALYZED, ViralVideoStatus.PENDING):
raise ValueError(f"Cannot resume from {self.status} to copy-gen")
for k, v in kwargs.items():
@@ -195,17 +183,16 @@ class ViralVideoJob:
self.updated_at = datetime.now(timezone.utc)
def resume_from_copy_generated(self, edited_copy: str | None = None) -> None:
"""阶段2->阶段3:用户确认/编辑文案,开始跑渲染。"""
"""阶段2->阶段3:用户确认/编辑口播文案,开始跑 TTS+单次Seedance渲染。"""
if self.status != ViralVideoStatus.COPY_GENERATED:
raise ValueError(f"Cannot resume from {self.status} to render")
if edited_copy:
self.user_copy_text = edited_copy
if edited_copy and isinstance(self.copy_result, dict):
self.copy_result = {**self.copy_result, "voiceover_script": edited_copy}
self.generated_copy_text = edited_copy
self.status = ViralVideoStatus.RUNNING
self.updated_at = datetime.now(timezone.utc)
def resume_from_confirm(self) -> None:
"""旧流水线兼容:从 WAIT_USER_CONFIRM 恢复。"""
if self.status != ViralVideoStatus.WAIT_USER_CONFIRM:
raise ValueError(f"Cannot resume from {self.status}")
self.status = ViralVideoStatus.RUNNING
@@ -240,5 +227,11 @@ class ViralVideoJob:
@property
def effective_copy_text(self) -> str:
"""渲染阶段使用的最终文案:用户编辑 > 生成文案 > 用户原始 > 占位。"""
return self.user_copy_text or self.generated_copy_text or "精选好物推荐"
"""TTS 用的最终口播文案:优先 copy_result.voiceover_script,兼容老字段。"""
if isinstance(self.copy_result, dict) and self.copy_result.get("voiceover_script"):
return self.copy_result["voiceover_script"]
return self.generated_copy_text or self.user_copy_text or "你好,给大家推荐一款好物"
@property
def voiceover_script(self) -> str:
return self.effective_copy_text
+47 -14
View File
@@ -250,21 +250,31 @@ class DoubaoClient:
duration: int = 5,
ratio: str | None = "9:16",
resolution: str = "720p",
generate_audio: bool = False,
generate_audio: bool = True,
watermark: bool = False,
output_dir: str | None = None,
model: str | None = None,
reference_images: list[str] | None = None,
reference_audios: list[str] | None = None,
reference_videos: list[str] | None = None,
) -> str | None:
"""调用 Seedance 2.5 文生/图生视频(异步任务→轮询→下载),返回本地 MP4 路径;失败返回 None。
v1.6: 支持多参考图(产品素材)+ 参考音频(TTS口型驱动)+ 参考视频,单次生成最长 30 秒。
Args:
prompt: 文本提示词
image_url: 首帧参考图 URL(可选,提供则走图生视频)
duration: 视频时长 2~30 秒,默认 5
ratio: 宽高比 16:9/9:16/1:1/4:3/3:4/21:9/adaptive
prompt: 文本提示词(含完整编导脚本:总览+场景光线+逐镜头时间轴+硬约束+负面词)
image_url: 首帧参考图 URL(可选,提供则走图生视频首帧模式,ratio 跟随首帧)
duration: 视频时长 4~30 秒
ratio: 宽高比 16:9/9:16/1:1/4:3/3:4/21:9/adaptive;image_url 存在时自动忽略
resolution: 480p/720p/1080p
generate_audio: 是否生成模型自带音效(默认 False,我们自己混 TTS)
generate_audio: 是否让模型原生合成音效/BGM(v1.6 默认 True,配合 reference_audios 做口型驱动)
watermark: 是否加水印
output_dir: 下载目录,默认 /tmp
model: 指定模型 ID;空则用 settings.doubao_video_model
reference_images: 多参考图 URL 列表(产品素材,最多30张;注意 image_url 为首帧单独传)
reference_audios: 参考音频 URL 列表(TTS口播,驱动口型,最多10段)
reference_videos: 参考视频 URL 列表(风格参考)
Returns:
本地 MP4 文件路径,失败返回 None。
@@ -276,23 +286,37 @@ class DoubaoClient:
settings = get_shared_settings()
poll_interval = getattr(settings, "doubao_video_poll_interval", 10) or 10
total_timeout = getattr(settings, "doubao_video_timeout", 600) or 600
video_model = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
total_timeout = getattr(settings, "doubao_video_timeout", 900) or 900
default_video_model = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
video_model = model or default_video_model
content: list[dict[str, Any]] = [{"type": "text", "text": prompt.strip()}]
if image_url:
content.append({"type": "image_url", "image_url": {"url": image_url}})
# v1.6: 多参考图(产品素材)
if reference_images:
for url in reference_images[:30]:
if url and isinstance(url, str):
content.append({"type": "image_url", "image_url": {"url": url}})
create_payload: dict[str, Any] = {
"model": video_model,
"content": content,
"generate_audio": generate_audio,
"generate_audio": bool(generate_audio),
"duration": int(duration),
"resolution": resolution,
"watermark": watermark,
"watermark": bool(watermark),
}
# Bug #2110: ratio=None 时不传(首帧图生视频跟随原图比例,传 ratio 会 400 InvalidParameter)
if ratio:
# v1.6: 参考音频(TTS 驱动口型)
if reference_audios:
create_payload["reference_audios"] = [
{"url": u, "role": "audio_url"} for u in reference_audios[:10] if u and isinstance(u, str)
]
# v1.6: 参考视频(风格参考)
if reference_videos:
create_payload["reference_videos"] = [{"url": u} for u in reference_videos[:5] if u and isinstance(u, str)]
# Bug #2110 / v1.6: ratio=None 时不传(首帧图生视频跟随原图比例)
if ratio and not image_url:
create_payload["ratio"] = ratio
headers = {
@@ -301,13 +325,16 @@ class DoubaoClient:
}
create_url = f"{self.base_url}/contents/generations/tasks"
logger.info(
"Seedance 创建任务请求: url=%s model=%s duration=%ds ratio=%s gen_audio=%s image_url=%s",
"Seedance 创建任务请求: url=%s model=%s duration=%ds ratio=%s gen_audio=%s image_url=%s ref_imgs=%d ref_audios=%d ref_videos=%d",
create_url,
video_model,
duration,
ratio or "(follow-image)",
generate_audio,
bool(image_url),
len(reference_images or []),
len(reference_audios or []),
len(reference_videos or []),
)
# 1) 创建任务(带重试)
@@ -356,7 +383,13 @@ class DoubaoClient:
)
return None
logger.info("Seedance 任务已创建: task_id=%s model=%s duration=%ds", task_id, video_model, duration)
logger.info(
"Seedance 任务已创建: task_id=%s model=%s duration=%ds gen_audio=%s",
task_id,
video_model,
duration,
generate_audio,
)
# 2) 轮询状态
poll_url = f"{create_url}/{task_id}"
+19 -8
View File
@@ -575,32 +575,43 @@ def call_video_generation(
prompt: str,
*,
image_url: str | None = None,
duration: int = 5,
duration: int = 15,
ratio: str | None = "9:16",
resolution: str = "720p",
output_dir: str | None = None,
model: str | None = None,
generate_audio: bool = True,
reference_images: list[str] | None = None,
reference_audios: list[str] | None = None,
reference_videos: list[str] | None = None,
) -> str | None:
"""调用 Seedance 2.5 生成视频段,返回本地 MP4 路径;失败返回 None。
"""调用 Seedance 2.5 生成视频(v1.6 单次出片版),返回本地 MP4 路径;失败返回 None。
封装 ai_client.video_generation:提交异步任务→轮询→下载到本地。
Bug #2110: 首帧参考图模式下不传 ratio(API 要求跟随首帧图比例,传 ratio=9:16
会返回 400 InvalidParameter)。
v1.6:
- 默认 generate_audio=True,模型原生合成环境音效/BGM;
- reference_audios 传 TTS 音频 URL 数组做口型驱动;
- reference_images 传产品素材 URL 数组做视觉参考;
- 单次最长 30 秒,不分段不拼接;
- image_url 存在时为「首帧图生视频」模式,自动不传 ratio(Bug #2110)。
"""
client = get_doubao_client()
if not client.is_available:
logger.warning("[ai_service] 豆包客户端未配置,跳过视频生成")
return None
# 首帧模式:不强制 ratio,让模型跟随首帧图比例
effective_ratio = None if image_url else ratio
try:
kwargs: dict = dict(
prompt=prompt,
image_url=image_url,
duration=duration,
duration=int(duration),
resolution=resolution,
generate_audio=False, # 我们自己混 TTS
generate_audio=bool(generate_audio),
watermark=False,
output_dir=output_dir,
model=model,
reference_images=reference_images,
reference_audios=reference_audios,
reference_videos=reference_videos,
)
if effective_ratio:
kwargs["ratio"] = effective_ratio
+19 -4
View File
@@ -368,6 +368,21 @@ fi
echo "All images pulled."
# ====== 打稳定 tag(:dev),供 Watchtower 监控 ======
# Watchtower 只能检测同一个 tag 的 digest 变化。
# commit SHA tag 每次构建都不同,Watchtower 无法感知更新。
# 因此每次部署都将最新镜像 tag 为 :dev,容器统一使用 :dev 启动。
DEV_API="${REGISTRY}/xiaoxia-saas-api:dev"
DEV_WORKER="${REGISTRY}/xiaoxia-saas-worker:dev"
DEV_WEB="${REGISTRY}/xiaoxia-saas-web:dev"
docker tag "$REGISTRY_API" "$DEV_API"
docker tag "$REGISTRY_WORKER" "$DEV_WORKER"
docker tag "$REGISTRY_WEB" "$DEV_WEB"
echo "✅ Tagged images as :dev for Watchtower monitoring"
echo " API: $DEV_API"
echo " Worker: $DEV_WORKER"
echo " Web: $DEV_WEB"
# ====== 镜像内容校验 ======
echo ""
echo "=========================================="
@@ -533,7 +548,7 @@ docker run -d \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$REGISTRY_API" &
"$DEV_API" &
PID_API_START=$!
# ── Worker: 通过 compose 启动(单一事实来源)──
@@ -541,7 +556,7 @@ PID_API_START=$!
# healthcheck 匹配 'celery.*worker'(不把 beat 算活)、资源限制 4C/8G。
# WORKER_IMAGE 通过环境变量覆盖镜像 tag(compose.yml 默认 :dev)。
echo "Starting worker via docker compose (from $INFRA_DOCKER_DIR)..."
WORKER_IMAGE="$REGISTRY_WORKER" APP_VERSION="$IMAGE_TAG" compose up -d --no-deps worker &
WORKER_IMAGE="$DEV_WORKER" APP_VERSION="$IMAGE_TAG" compose up -d --no-deps worker &
PID_WORKER_START=$!
# ── Web: 暂保留 docker run(TODO: 后续收敛到 compose)──
@@ -557,7 +572,7 @@ docker run -d \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$REGISTRY_WEB" &
"$DEV_WEB" &
PID_WEB_START=$!
wait $PID_API_START $PID_WORKER_START $PID_WEB_START
@@ -688,5 +703,5 @@ echo "=== Staging deployment complete ==="
echo "API: http://127.0.0.1:8000"
echo "Web: http://127.0.0.1:3001"
echo "Worker: managed by docker compose (project=$COMPOSE_PROJECT)"
echo "Version: $IMAGE_TAG"
echo "Version: $IMAGE_TAG (running as :dev for Watchtower)"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
+104 -51
View File
@@ -120,7 +120,7 @@ class TestViralVideoJobDefaults:
job = ViralVideoJob(user_id="u1")
assert job.images == []
assert job.industry == ""
assert job.duration == 30
assert job.duration == 15
assert job.fusion_level == FusionLevel.AI_POLISH
assert job.style_strength == StyleStrength.MEDIUM
assert job.status == ViralVideoStatus.PENDING
@@ -145,13 +145,10 @@ class TestViralVideoStage:
"image_analysis",
"video_analysis",
"intent_parsing",
"copy_fusion",
"storyboard",
"script_generation",
"review",
"tts",
"bgm_select",
"rendering",
"musetalk",
"uploading",
]
actual_order = [s.value for s in ViralVideoStage]
@@ -171,7 +168,7 @@ class TestViralVideoSchemas:
assert req.images == ["https://example.com/img.jpg"]
assert req.fusion_level == "ai_polish"
assert req.style_strength == "medium"
assert req.duration == 30
assert req.duration == 15
def test_create_request_empty_images_raises(self):
from app.schemas.viral_video import CreateViralVideoRequest
@@ -365,9 +362,10 @@ class TestViralVideoPipeline:
industry="美妆",
target_customer="年轻女性",
marketing_purpose="品牌推广",
duration=30,
duration=15,
user_copy_text="这款产品超好用",
fusion_level="ai_polish",
video_ratio="9:16",
)
@patch("packages.shared.ai_service.call_vision")
@@ -405,63 +403,110 @@ class TestViralVideoPipeline:
assert "intent" in result
@patch("packages.shared.ai_service.call_llm")
def test_copy_fusion_ai_polish(self, mock_llm, mock_job):
from apps.worker.worker_app.tasks.viral_video import _step_copy_fusion
def test_script_generation_returns_copy_result(self, mock_llm, mock_job):
"""v1.6: _step_script_generation 返回 dict 形式的 CopyResult,含 voiceover_script + shots。"""
from apps.worker.worker_app.tasks.viral_video import _step_script_generation
mock_llm.return_value = "融合后的文案内容"
result = _step_copy_fusion(mock_job, {"intent": "推广"}, {"products": []})
assert isinstance(result, str)
assert len(result) > 0
mock_llm.return_value = {
"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,
},
{
"time_range": "5-15秒",
"shot_type_angle_movement": "特写,固定镜头",
"scene_and_dialogue": "涂抹口红:颜色特别好看很显白",
"action_details": "嘴唇涂抹特写",
"audio_bgm": "轻快BGM继续",
"transition": "结束",
"reference_image_index": 1,
},
],
"hard_constraints": ["无字幕无水印"],
"negative_prompts": ["字幕", "水印"],
"voiceover_script": "大家好,今天分享一款口红,颜色特别好看很显白。",
}
result = _step_script_generation(
mock_job, {"intent": "推广口红", "key_messages": [], "tone": "亲切"}, {"products": []}
)
assert isinstance(result, dict)
assert "voiceover_script" in result
assert "shots" in result
assert isinstance(result["shots"], list)
assert len(result["shots"]) == 2
assert result["overview"]["total_duration"] == 15
# final_copy 必须 = voiceover_script(向后兼容)
assert result.get("final_copy") == result["voiceover_script"]
@patch("packages.shared.ai_service.call_llm")
def test_storyboard_generation(self, mock_llm, mock_job):
from apps.worker.worker_app.tasks.viral_video import _step_storyboard
def test_script_generation_fallback(self, mock_llm, mock_job):
"""LLM 返回异常时使用兜底脚本(不会抛错)。"""
from apps.worker.worker_app.tasks.viral_video import _fallback_script
mock_llm.return_value = [
{"order": 0, "type": "product_shot", "duration": 10},
{"order": 1, "type": "closing", "duration": 5},
]
result = _step_storyboard(mock_job, "测试文案", {})
assert isinstance(result, list)
assert len(result) == 2
result = _fallback_script(mock_job)
assert isinstance(result, dict)
assert result["voiceover_script"]
assert len(result["shots"]) >= 1
@patch("packages.shared.ai_service.call_llm")
def test_review_pass(self, mock_llm, mock_job):
def test_review_pass_v16(self, mock_llm, mock_job):
"""v1.6 _step_review 接收 copy_result dict。"""
from apps.worker.worker_app.tasks.viral_video import _step_review
mock_llm.return_value = {"passed": True, "score": 90, "details": {}}
result = _step_review(mock_job, "测试文案", [])
cr = {"voiceover_script": "大家好", "shots": []}
result = _step_review(mock_job, cr)
assert result["passed"] is True
def test_bgm_select(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
def test_assemble_seedance_prompt(self, mock_job):
"""编导脚本必须能拼出完整的 Seedance prompt,含总览/场景/逐镜头/约束。"""
from apps.worker.worker_app.tasks.viral_video import _assemble_seedance_prompt
# P1: BGM 素材未就绪前 _step_bgm_select 统一返回 None(跳过 BGM 混音)
mock_job.bgm_preference = "upbeat"
bgm = _step_bgm_select(mock_job)
assert bgm is None
def test_bgm_select_default(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
mock_job.bgm_preference = ""
bgm = _step_bgm_select(mock_job)
assert bgm is None
cr = {
"overview": {"theme": "口红", "total_duration": 15, "aspect_ratio": "9:16"},
"scene_and_lighting": "明亮化妆台",
"shots": [
{
"time_range": "0-15秒",
"shot_type_angle_movement": "中景平视",
"scene_and_dialogue": "你好分享",
"action_details": "展示",
"audio_bgm": "BGM",
"transition": "结束",
"reference_image_index": 0,
}
],
"hard_constraints": ["无字幕"],
"negative_prompts": ["水印"],
}
prompt = _assemble_seedance_prompt(cr, mock_job)
assert "【视频总览】" in prompt
assert "【逐镜头时间轴】" in prompt
assert "【硬性约束】" in prompt
assert "【负面提示词】" in prompt
assert "0-15秒" in prompt
# ── 端到端流水线集成测试 ────────────────────────────────────────────────
class TestPipelineIntegration:
"""流水线端到端集成测试(mock 外部依赖)。"""
"""v1.6 流水线端到端集成测试(mock 外部依赖):TTS+单次 Seedance+上传。"""
@patch("apps.worker.worker_app.tasks.viral_video._step_upload")
@patch("apps.worker.worker_app.tasks.viral_video._step_render")
@patch("apps.worker.worker_app.tasks.viral_video._step_bgm_select")
@patch("apps.worker.worker_app.tasks.viral_video._upload_tts_to_oss")
@patch("apps.worker.worker_app.tasks.viral_video._step_tts")
@patch("apps.worker.worker_app.tasks.viral_video._step_review")
@patch("apps.worker.worker_app.tasks.viral_video._step_storyboard")
@patch("apps.worker.worker_app.tasks.viral_video._step_copy_fusion")
@patch("apps.worker.worker_app.tasks.viral_video._step_script_generation")
@patch("apps.worker.worker_app.tasks.viral_video._step_intent_parsing")
@patch("apps.worker.worker_app.tasks.viral_video._step_video_analysis")
@patch("apps.worker.worker_app.tasks.viral_video._step_image_analysis")
@@ -474,38 +519,46 @@ class TestPipelineIntegration:
mock_img_analysis,
mock_video_analysis,
mock_intent,
mock_copy_fusion,
mock_storyboard,
mock_script,
mock_review,
mock_tts,
mock_bgm,
mock_tts_upload,
mock_render,
mock_upload,
):
"""测试 resume 流水线能从确认状态走到完成。"""
"""v1.6: TTS整段合成 → 上传TTS到OSS → 单次 Seedance → 上传成片。"""
from apps.worker.worker_app.tasks.viral_video import (
resume_viral_video_pipeline,
)
# 构造 mock job
job = ViralVideoJob(
user_id="user-001",
images=["https://img.com/1.jpg"],
industry="美妆",
status=ViralVideoStatus.RUNNING,
intent_result={"intent": "推广"},
duration=15,
video_ratio="9:16",
)
mock_repo = MagicMock()
mock_session = MagicMock()
mock_get_repo.return_value = (mock_session, mock_repo, job)
# 设置各步骤返回值
mock_copy_fusion.return_value = "融合文案"
mock_storyboard.return_value = [{"order": 0, "duration": 10}]
# v1.6: 如果没有 copy_result 会现场补生成
mock_intent.return_value = {"intent": "推广", "key_messages": [], "tone": "亲切"}
mock_script.return_value = {
"overview": {"theme": "口红", "total_duration": 15, "aspect_ratio": "9:16"},
"scene_and_lighting": "明亮化妆台",
"shots": [],
"hard_constraints": [],
"negative_prompts": [],
"voiceover_script": "大家好,分享一款口红。",
"final_copy": "大家好,分享一款口红。",
}
mock_review.return_value = {"passed": True, "score": 90}
mock_tts.return_value = None # P1: TTS 返回 Path|None,mock 用 None 跳过混音
mock_bgm.return_value = None # P1: BGM 未就绪前返回 None
mock_tts.return_value = None # TTS 失败也能走下去(Seedance generate_audio=True 会自己合成音效)
mock_tts_upload.return_value = None
mock_render.return_value = "/tmp/video.mp4"
mock_upload.return_value = "https://oss.example.com/final.mp4"
+121 -50
View File
@@ -67,49 +67,70 @@ class TestImageAnalysisField:
# ── P0-1: storyboard 规范化 ────────────────────────────────────────
class TestStoryboardNormalize:
def test_normalize_fills_defaults(self):
from apps.worker.worker_app.tasks.viral_video import _normalize_storyboard
class TestScriptGenerationV16:
"""v1.6 编导分镜脚本生成相关纯函数测试。"""
raw = [{"order": 0, "description": "镜头一"}]
out = _normalize_storyboard(raw, total_duration=10, n_segments=1, copy_text="文案")
assert len(out) == 1
assert out[0]["duration"] >= 3
assert out[0]["ken_burns"] in {"zoom_in", "zoom_out", "pan_left", "pan_right", "static"}
assert out[0]["type"] == "product_shot"
assert out[0]["text"] == ""
def test_fallback_script_has_required_fields(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _fallback_script
def test_normalize_scales_to_total_duration(self):
from apps.worker.worker_app.tasks.viral_video import _normalize_storyboard
out = _fallback_script(mock_job)
assert isinstance(out, dict)
assert "overview" in out
assert "shots" in out
assert "voiceover_script" in out
assert "hard_constraints" in out
assert "negative_prompts" in out
assert out["overview"]["total_duration"] == mock_job.duration
assert out["final_copy"] == out["voiceover_script"]
assert len(out["shots"]) >= 1
raw = [
{"order": 0, "duration": 10, "description": "a"},
{"order": 1, "duration": 10, "description": "b"},
]
out = _normalize_storyboard(raw, total_duration=10, n_segments=2, copy_text="x")
total = sum(s["duration"] for s in out)
assert total == 10
def test_safe_json_loads_parses_fenced_code(self):
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
def test_fallback_storyboard(self):
from apps.worker.worker_app.tasks.viral_video import _fallback_storyboard
fenced = '```json\n{"voiceover_script": "你好", "shots": []}\n```'
out = _safe_json_loads(fenced)
assert out is not None
assert out["voiceover_script"] == "你好"
out = _fallback_storyboard("文案", total_duration=15, n_segments=3)
assert len(out) == 3
assert sum(s["duration"] for s in out) == 15
assert all(s["duration"] >= 3 for s in out)
def test_safe_json_loads_handles_none(self):
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
def test_storyboard_llm_list(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _step_storyboard
assert _safe_json_loads(None) is None
assert _safe_json_loads("not json") is None
with patch("packages.shared.ai_service.call_llm") as mock_llm:
mock_llm.return_value = [
{"order": 0, "description": "产品特写", "duration": 5, "text": "t1"},
{"order": 1, "description": "使用场景", "duration": 5, "text": "t2"},
{"order": 2, "description": "CTA", "duration": 5, "text": "t3"},
]
result = _step_storyboard(mock_job, "文案", {"products": []})
assert len(result) == 3
assert all("description" in s for s in result)
def test_validate_normalize_fills_defaults(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _validate_and_normalize_script
raw = {"voiceover_script": "你好", "shots": [{"scene_and_dialogue": "测试"}]}
out = _validate_and_normalize_script(raw, mock_job)
assert out["voiceover_script"] == "你好"
assert len(out["shots"]) == 1
assert out["shots"][0]["shot_type_angle_movement"]
assert out["overview"]["total_duration"] == mock_job.duration
def test_assemble_seedance_prompt_contains_sections(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _assemble_seedance_prompt
cr = {
"overview": {"theme": "测试", "total_duration": 15, "aspect_ratio": "9:16"},
"scene_and_lighting": "明亮",
"shots": [
{
"time_range": "0-15秒",
"shot_type_angle_movement": "中景",
"scene_and_dialogue": "你好",
"action_details": "展示",
"audio_bgm": "BGM",
"transition": "结束",
"reference_image_index": 0,
}
],
"hard_constraints": ["无字幕"],
"negative_prompts": ["水印"],
}
p = _assemble_seedance_prompt(cr, mock_job)
for key in ("【视频总览】", "【场景与光线】", "【逐镜头时间轴】", "【硬性约束】", "【负面提示词】"):
assert key in p
# ── P1: TTS 返回 Path|None ────────────────────────────────────────
@@ -150,12 +171,24 @@ class TestTTSPath:
# ── P1: BGM 跳过 / MuseTalk 无 persona 跳过 ───────────────────────
class TestBGMSkip:
def test_bgm_returns_none(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
class TestDurationClamp:
"""v1.6 mark_copy_generated 派生字段 + duration clamp。"""
mock_job.bgm_preference = "upbeat"
assert _step_bgm_select(mock_job) is None
def test_mark_copy_generated_derives_fields(self):
job = ViralVideoJob(user_id="u1", duration=15)
cr = {
"overview": {"theme": "x", "total_duration": 15, "aspect_ratio": "9:16"},
"scene_and_lighting": "亮",
"shots": [{"time_range": "0-15秒", "scene_and_dialogue": "对白"}],
"voiceover_script": "你好",
"hard_constraints": [],
"negative_prompts": [],
}
job.mark_copy_generated(cr)
assert job.copy_result is cr
assert job.generated_copy_text == "你好"
assert job.storyboard == cr["shots"]
assert job.effective_copy_text == "你好"
# ── P0-1: call_video_generation 参数构造 ──────────────────────────
@@ -188,23 +221,61 @@ class TestCallVideoGeneration:
assert kwargs["prompt"] == "测试"
assert kwargs["image_url"] == "https://img/x.jpg"
assert kwargs["duration"] == 5
assert kwargs["generate_audio"] is True
# ── P0-1: _step_render 占位片段生成 ──────────────────────────────
class TestPlaceholderClip:
def test_make_placeholder_clip(self, tmp_path):
import shutil
class TestCallVideoGenerationV16:
"""v1.6 call_video_generation 透传 reference_audios/reference_images 等参数到 client。"""
from apps.worker.worker_app.tasks.viral_video import _make_placeholder_clip, _probe_ok
def test_passes_reference_params_to_client(self, tmp_path):
from packages.shared.ai_service import call_video_generation
if not shutil.which("ffmpeg"):
pytest.skip("ffmpeg not available")
out = tmp_path / "v.mp4"
out.write_bytes(b"fake")
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
mock_client = MagicMock()
mock_client.is_available = True
mock_client.video_generation.return_value = str(out)
mock_get.return_value = mock_client
result = call_video_generation(
prompt="测试",
image_url="https://img/x.jpg",
duration=15,
ratio="9:16",
reference_images=["https://img/r1.jpg"],
reference_audios=["https://oss/tts.mp3"],
reference_videos=["https://oss/ref.mp4"],
generate_audio=True,
model="doubao-seedance-2-5-260628",
)
assert result == str(out)
kwargs = mock_client.video_generation.call_args.kwargs
# 首帧模式不传 ratio(Bug #2110)
assert "ratio" not in kwargs
assert kwargs["image_url"] == "https://img/x.jpg"
assert kwargs["reference_audios"] == ["https://oss/tts.mp3"]
assert kwargs["reference_images"] == ["https://img/r1.jpg"]
assert kwargs["reference_videos"] == ["https://oss/ref.mp4"]
assert kwargs["generate_audio"] is True
assert kwargs["model"] == "doubao-seedance-2-5-260628"
out = _make_placeholder_clip(tmp_path, 0, 3)
assert out.exists()
assert _probe_ok(str(out))
def test_ratio_passed_when_no_image(self, tmp_path):
from packages.shared.ai_service import call_video_generation
out = tmp_path / "v.mp4"
out.write_bytes(b"fake")
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
mock_client = MagicMock()
mock_client.is_available = True
mock_client.video_generation.return_value = str(out)
mock_get.return_value = mock_client
call_video_generation(prompt="测试", duration=10, ratio="16:9")
kwargs = mock_client.video_generation.call_args.kwargs
assert kwargs["ratio"] == "16:9"
assert kwargs["image_url"] is None
# ── P0-1: DoubaoClient.video_generation 在不可用时返回 None ───────
+75 -1
View File
@@ -34,7 +34,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"viral_structure": "",
"marketing_purpose": "",
"bgm_preference": "",
"duration": 30,
"duration": 15,
"user_copy_text": "",
"fusion_level": "ai_polish",
"reference_audio_path": "",
@@ -53,9 +53,18 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"intent_result": None,
"image_analysis": None,
"storyboard": None,
"copy_result": None,
"generated_copy_text": "",
"voice_id": "",
"voice_source": "",
"voice_mode": "global",
"video_ratio": "9:16",
"video_model": "",
"credits_cost": 0,
"updated_at": None,
"is_terminal": False,
"effective_copy_text": "",
"voiceover_script": "",
}.items():
setattr(job, k, kwargs.pop(k, v))
return job
@@ -277,6 +286,71 @@ class TestGenerateCopy:
vv_mod.generate_copy("job-gc2", GenerateCopyRequest(), authenticated_user=user, session=session)
assert exc.value.status_code == 409
def test_generate_copy_persists_voice_and_ratio(self):
"""generate-copy 应把 voice_id/voice_source/video_ratio 写入 job。"""
from unittest.mock import patch
from app.api.routes import viral_video as vv_mod
from app.schemas.viral_video import GenerateCopyRequest
from packages.domain.viral_video import ViralVideoStatus
user = _auth_user("u1")
session = MagicMock()
job = _make_job(job_id="job-gc3", user_id="u1", status=ViralVideoStatus.IMAGE_ANALYZED)
repo = MagicMock()
repo.get.return_value = job
req = GenerateCopyRequest(
voice_id="cosy_voice_001",
voice_source="library",
video_ratio="16:9",
)
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch.object(vv_mod.celery_app, "send_task"),
):
vv_mod.generate_copy("job-gc3", req, authenticated_user=user, session=session)
assert job.voice_id == "cosy_voice_001"
assert job.voice_source == "library"
assert job.video_ratio == "16:9"
class TestAnalyzeImagesPersist:
def test_analyze_images_persists_voice_and_ratio(self):
"""analyze-images 创建任务时应带上 voice/video_ratio 字段。"""
from unittest.mock import patch
from app.api.routes import viral_video as vv_mod
from app.schemas.viral_video import AnalyzeImagesRequest
user = _auth_user("u1")
session = MagicMock()
saved = {}
class FakeRepo:
def save(self, job):
saved["job"] = job
def get(self, jid):
return None
req = AnalyzeImagesRequest(
images=["img-1"],
voice_id="preset_v1",
voice_source="preset",
video_ratio="1:1",
)
with (
patch.object(vv_mod, "_get_job_repo", return_value=FakeRepo()),
patch.object(vv_mod.celery_app, "send_task"),
):
resp = vv_mod.analyze_images(req, authenticated_user=user, session=session)
job = saved["job"]
assert job.voice_id == "preset_v1"
assert job.voice_source == "preset"
assert job.video_ratio == "1:1"
assert resp.images == ["img-1"]
class TestConfirmCopy:
def test_confirm_copy_dispatches_render(self):