Compare commits

...

38 Commits

Author SHA1 Message Date
xiaoxia 4364bc951d feat(viral-video): 角色三视图持久化到OSS(替代临时URL) (#2286)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m25s
CI/CD Pipeline / Integration Tests (push) Successful in 4m5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m39s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m24s
CI/CD Pipeline / Validate - Style (push) Successful in 4m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / Validate - Security (push) Successful in 6m13s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 6m56s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m44s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m54s
CI/CD Pipeline / Unit Tests (push) Successful in 12m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 45s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 52s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m9s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m46s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m37s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-13 02:42:22 +08:00
xiaoxia 495506e2a1 feat(viral-video): MVP 自动生成角色三视图绕过肖像审核(50411) (#2285)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 54s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m8s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m19s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m26s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m56s
CI/CD Pipeline / Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m35s
CI/CD Pipeline / Validate - Style (push) Successful in 3m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m31s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m56s
CI/CD Pipeline / Validate - Security (push) Successful in 6m58s
AI Code Review / AI Code Review (pull_request) Successful in 7m0s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m35s
CI/CD Pipeline / Unit Tests (push) Successful in 15m46s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-13 02:14:32 +08:00
xiaoxia 749cee9838 refactor(viral-video): 删除intent_parsing死代码和旧一键流水线 (#2284)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 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 / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m26s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m27s
CI/CD Pipeline / CI Gate (pull_request) Successful in 14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m41s
CI/CD Pipeline / Integration Tests (push) Successful in 4m46s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m7s
CI/CD Pipeline / Validate - Style (push) Successful in 5m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m11s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 6m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m26s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 7m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m31s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m38s
AI Code Review / AI Code Review (pull_request) Successful in 11m13s
CI/CD Pipeline / Unit Tests (push) Successful in 13m11s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-13 00:35:40 +08:00
xiaoxia 35f04f746b Merge pull request 'fix(viral-video): seed脚本 engine 获取修复' (#2283) from fix/viral-video-seed-script-engine into develop
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 57s
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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 48s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m25s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m49s
CI/CD Pipeline / Integration Tests (push) Successful in 5m14s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m9s
CI/CD Pipeline / Validate - Style (push) Successful in 6m5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Unit Tests (push) Successful in 13m17s
CI/CD Pipeline / Validate - Security (push) Successful in 17m18s
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-12 21:22:44 +08:00
CI Bot 8ea3e26ab6 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 0s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m41s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m47s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m34s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m52s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
AI Code Review / AI Code Review (pull_request) Successful in 7m7s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m22s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 9m9s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m41s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 19m6s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m2s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 26s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m9s
2026-10-12 12:47:59 +00:00
CI Bot 769590a525 fix(seed): 补全 marketing_purpose/target_customer 两类默认seed数据(共52条)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m51s
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 15s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / 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 / Validate - Security (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 / Integration Tests (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m3s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m37s
- marketing_purpose 20 条(对应前端 PURPOSES 枚举:获客引流/口播带货/对话短剧等)
- target_customer 8 条(对应前端 TARGET_CUSTOMERS 枚举:小镇青年/Z世代/精致妈妈等)
- seed总数:persona×12 + structure×5 + language×7 + marketing_purpose×20 + target_customer×8 = 52条
- 每条guide_text按该分类的创作指导风格撰写,前端选对应选项时精确命中DB返回专业hint
- 代码内关键词模糊兜底逻辑继续保留,未命中DB的自定义输入仍可正常工作
2026-10-12 20:37:45 +08:00
CI Bot 3df88c48fd fix(seed): 修复 viral_video_param_guides seed 脚本 engine 获取逻辑(_session_mod.engine 不存在导致 AttributeError)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m47s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (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 / Production Browser E2E (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 / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
- 删除对 packages.adapters.sqlalchemy_impl.session.engine 的引用(session 模块只有 build_engine 工厂,无全局 engine)
- 改为与 seed_viral_video_prompts.py 一致:优先 DATABASE_URL 环境变量,fallback 到 get_shared_settings().database_url
- driver 统一为 postgresql+psycopg2://(与其他 seed 脚本一致)
- 不影响线上运行(代码内置 _DEFAULT_* 兜底),生产部署时必须用此修复版 seed
2026-10-12 20:33:56 +08:00
xiaoxia 6ab953ae2a Merge pull request 'refactor(viral-video): PR3 后端P2 统一异常+清理' (#2281) from refactor/viral-video-backend-p2-errors-and-cleanup into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m7s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m28s
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 / 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 API Image (push) Successful in 1m32s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
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 32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m26s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
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 2m24s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m23s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m20s
CI/CD Pipeline / Validate - Style (push) Successful in 6m11s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m24s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
AI Code Review / AI Code Review (pull_request) Successful in 7m0s
CI/CD Pipeline / Integration Tests (push) Failing after 8m18s
CI/CD Pipeline / Unit Tests (push) Successful in 13m29s
CI/CD Pipeline / Validate - Security (push) Successful in 19m22s
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-12 20:22:48 +08:00
xiaoxia 73294c6440 Merge pull request 'refactor(web/viral-video): PR6 前端P2 CSS瘦身+打磨' (#2282) from refactor/viral-video-frontend-p2-css-and-polish into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m13s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m25s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m57s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (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 / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m23s
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 / Retag skipped Staging Web Image (pull_request) Has been skipped
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 / Retag skipped Staging API Image (pull_request) Has been skipped
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 - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m6s
2026-10-12 20:10:55 +08:00
xiaoxia c96b6de053 test(viral-video): fix mock leakage in p2_errors tests (monkeypatch restore)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m38s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 56s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m57s
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 2m11s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m23s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m45s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m49s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m11s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 15m11s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m5s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m21s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m53s
2026-10-12 20:04:36 +08:00
xiaoxia 02660b063c Merge pull request 'ci(ditto): DITTO_EMOTION_TIMEOUT secret 注入部署渲染链路' (#2280) from fix/ditto-timeout-ci-config into develop
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) 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 / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 55s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m27s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m50s
CI/CD Pipeline / Integration Tests (push) Successful in 4m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m10s
AI Code Review / AI Code Review (pull_request) Successful in 7m4s
CI/CD Pipeline / Validate - Style (push) Successful in 7m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m45s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m53s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API 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 / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-10-12 20:01:33 +08:00
xiaoxia fd977991bd style(web): prettier --write 5 files (CI lint)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 12s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m28s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m31s
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 / Frontend Lint (pull_request) Successful in 2m53s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m19s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 10m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m25s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 16m8s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 45s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m27s
2026-10-12 19:38:48 +08:00
CI Bot ce9da8cbfe 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 9s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
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 21s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m37s
AI Code Review / AI Code Review (pull_request) Successful in 6m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m57s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 6m41s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m43s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 14m39s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 16m57s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m10s
2026-10-12 11:38:03 +00:00
自动化测试Agent fae974b533 ci: trigger pipeline (PR #2280 漏触发补跑)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 34s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m16s
AI Code Review / AI Code Review (pull_request) Successful in 6m57s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m33s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 7m15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 17m3s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 18m4s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m44s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 43s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m10s
2026-10-12 19:34:57 +08:00
自动化测试Agent 9b5f92a05d ci(ditto): DITTO_EMOTION_TIMEOUT secret 注入部署渲染链路
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / 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 / Integration Tests (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Gitea Secret DITTO_EMOTION_TIMEOUT=30 已创建,但三处代码缺失导致
secret 无法注入 .env,部署后 timeout 回退到代码默认值。三处联动补齐:
1. deploy/configs/.env.staging:新增 DITTO_EMOTION_TIMEOUT=${DITTO_EMOTION_TIMEOUT} 占位符
2. ci-pipeline.yml Deploy Staging 的 Render .env 步骤 env 列表新增
   DITTO_EMOTION_TIMEOUT: "${{ secrets.DITTO_EMOTION_TIMEOUT }}"
3. scripts/render_env.sh:SHARED_SECRETS 列表新增 DITTO_EMOTION_TIMEOUT
   (必需:模板占位符变量不在列表中会触发 missing 报错导致渲染失败)

本地模拟渲染验证通过:DITTO_EMOTION_TIMEOUT=30 正确替换。
production 链路未动:base.py 默认值已为 30 秒,如需 secret 可配再补
2026-10-12 19:29:27 +08:00
agent e9128449ef refactor(viral-video): PR3 P2 errors+cleanup (统一异常/兜底文案/Seedance拆分/常量/param_guides DB)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
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 15s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 43s
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
AI Code Review / AI Code Review (pull_request) Successful in 6m56s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- 新建 packages/domain/viral_video/ 包(原 viral_video.py 拆为 entities.py + __init__.py 重导出)
- 新增 errors.py: ViralVideoError 基类 + Config/Upstream/Compliance/Resource/Credits/Timeout/LLM 7 个子类
- 新增 stages.py: STAGE_* / PROGRESS_* 阶段/进度常量集中维护
- worker viral_video.py 主路径 RuntimeError/ValueError → ViralVideo* 异常;
  顶层 except 拆 ViralVideoError 分支,用 user_message 做前端展示不打堆栈
- _fallback_script 去掉8条硬编码营销套话,改为"来看看{brand}家的{name}"等诚实短文案
- _step_video_analysis ImportError 不再返回占位假风格 dict,返回 None 下游跳过
- _assemble_seedance_prompt 拆分:抽取 _SEEDANCE_* / _WAN_* 常量 + _build_style_section + _build_character_section,主函数减 70+ 行,输出不变
- prompt_loader.py 4处静默 except 改为首次 warning(_warned set + 5min 冷却去重)
- 新建 packages/application/viral_video/param_guides.py: DB 读 viral_video_param_guides,30s TTL 缓存
- worker 5个 hint 函数(persona/structure/marketing_purpose/target_customer/language)改为 DB 优先 + _DEFAULT_* 代码兜底
- 新增迁移 109_add_viral_video_param_guides.py(5种 guide_type, UNIQUE(type,key))
- 新增 scripts/seed_viral_video_param_guides.py 幂等灌入默认值
- 新增 tests/unit/test_viral_video_p2_errors.py 6 个测试,修复既有测试引用 _PERSONA_STYLE_GUIDE → _DEFAULT_PERSONA_HINTS

测试:pytest tests/unit/ -k viral → 254 passed, 2 skipped
验收:worker/API import ok;alembic heads → 109 (head)
2026-10-12 19:25:48 +08:00
CI Bot 90ec76ce92 refactor(web/viral-video): PR6 P2清理 - CSS瘦身/inlie style迁移/抖音空功能删除/短剧提示条迁移/GENDER_LABEL修复/console清理
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (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) Failing after 1m12s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m18s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m23s
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
- GENDER_LABEL: 修复"女声·女声"/"男声·男声"重复拼接bug,改为"女声"/"男声"
- douyin空功能清理: 删除handleDouyinUrl空函数、抖音链接识别按钮/输入框UI、douyinInput state、VoicePanel中'douyin'分支、RefVideo.douyinUrl字段
- 短剧提示条: 从ViralVideoPage底部(grid外)迁移到Step2CopyPanel内部就地显示(选短剧类营销目的时显示),inline style抽为.vv-shortdrama-hint class,文案改为'💡 短剧模式已选中,对手戏角色将自动分配配角音色'
- copyError持久显示: 合规审核失败错误条从step3列移到Step2CopyPanel文案区持久显示(原只toast 3秒+step3列显示,不在文案区就近)
- CSS清理: 删除未使用class约25个(v1残留/旧结构),CSS 2274→2157行(-117行)
- inline style迁移CSS: speaker标签(.vv-sb-speaker-tag-main/guest)、spinner、voice空态、loading图标、margin/font-size helpers等静态多属性inline style抽为class
- console清理: 删除2处console.warn调试日志
- 不动API契约/三列等高布局/CSS Module,复用ErrorRetryBar/VVSpinner组件

tsc --noEmit: 0 error
2026-10-12 19:14:34 +08:00
xiaoxia 32f105b3cc Merge pull request 'refactor(viral-video): PR5-前端P1组件大拆分(主页面2939→558行)' (#2277) from refactor/viral-video-frontend-p1-component-split 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 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / 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 / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (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 / Build Staging Web Image (push) Successful in 1m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 58s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m31s
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 33s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m54s
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 / Frontend Unit Tests (push) Failing after 3m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Validate - Style (push) Successful in 6m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m1s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m19s
AI Code Review / AI Code Review (pull_request) Successful in 7m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m47s
CI/CD Pipeline / Integration Tests (push) Failing after 8m37s
CI/CD Pipeline / Unit Tests (push) Successful in 14m48s
CI/CD Pipeline / Validate - Security (push) Successful in 16m13s
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
2026-10-12 18:54:15 +08:00
xiaoxia 17f0f9682b fix(ditto): 分句短句合并防止切太碎 + LLM超时默认调大到30秒 (#2279)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 47s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) 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 API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m6s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging 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 / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-12 18:51:49 +08:00
xiaoxia c822cfe69b Merge branch 'develop' into refactor/viral-video-frontend-p1-component-split
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m8s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m56s
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
AI Code Review / AI Code Review (pull_request) Successful in 5m38s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m17s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m58s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 14m13s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
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 36s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m41s
2026-10-12 18:36:47 +08:00
xiaoxia 3b71a96b0a fix(web): move resolveModelSwitch to utils, drop any cast (CI lint)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / 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 API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (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 / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
2026-10-12 18:36:22 +08:00
xiaoxia bee1cdc060 Merge pull request 'refactor(viral-video): PR2-后端P1流程直线化+装饰器+积分结算提取(净减112行)' (#2278) from refactor/viral-video-backend-p1-linearize-and-simplify into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration 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 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m54s
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m43s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m26s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 8m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 9m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m43s
CI/CD Pipeline / Integration Tests (push) Failing after 9m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m37s
CI/CD Pipeline / Unit Tests (push) Successful in 15m36s
CI/CD Pipeline / Validate - Security (push) Successful in 20m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
2026-10-12 18:09:28 +08:00
xiaoxia 50ef39a7ef merge: 采纳 develop ditto 的 B005 正则实现,消除与 da5e9d4e 的合并冲突
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 / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m37s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 20s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m7s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m9s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m50s
AI Code Review / AI Code Review (pull_request) Successful in 6m52s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 10m8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 59s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 10m22s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 11m28s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 12m28s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 22m25s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 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
2026-10-12 18:07:42 +08:00
CI Bot 88fa27e3aa style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 11s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m43s
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
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
2026-10-12 09:29:49 +00:00
xiaoxia 74d23895d5 fix(lint): rstrip 多字符集合加 noqa: B005(语义有意为之,非bug)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
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 17s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m21s
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
AI Code Review / AI Code Review (pull_request) Successful in 6m44s
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 / 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 / Unit Tests (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 / Integration Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 42s
2026-10-12 17:17:27 +08:00
xiaoxia da5e9d4e56 Merge pull request 'fix(ditto): 情绪分析彻底修复——智能分句+8表情开放+prompt few-shot' (#2276) from fix/ditto-emotion-thorough-fix 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 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m56s
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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 57s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m7s
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 Staging Worker Image (push) Successful in 1m46s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m38s
AI Code Review / AI Code Review (pull_request) Successful in 7m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 10m22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m1s
CI/CD Pipeline / Integration Tests (push) Successful in 9m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 11m43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m52s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m55s
CI/CD Pipeline / Unit Tests (push) Successful in 18m45s
CI/CD Pipeline / Validate - Security (push) Successful in 34m7s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-10-12 17:13:59 +08:00
CI Bot e8c23d6ffa 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 2s
CI/CD Pipeline / Build Staging API Image (pull_request) 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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m47s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m56s
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 / 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 / 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 / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
2026-10-12 09:07:10 +00:00
CI Bot 599ae3fdc8 Merge remote-tracking branch 'origin/develop' into refactor/viral-video-backend-p1-linearize-and-simplify
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
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) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / 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 / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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 / Integration Tests (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 45s
AI Code Review / AI Code Review (pull_request) Successful in 7m23s
2026-10-12 16:47:55 +08:00
CI Bot cfd37a39d1 merge CI auto-format, keep ViralVideoStage import in errorClassification
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (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 / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m27s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m56s
AI Code Review / AI Code Review (pull_request) Successful in 6m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Failing after 12m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 16m18s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 36m10s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 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
2026-10-12 16:47:45 +08:00
CI Bot 0d20c82145 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (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 / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
2026-10-12 08:46:19 +00:00
CI Bot dfb81a803e merge develop into PR5 branch, resolve ViralVideoPage.tsx conflict (keep PR5 rewrite) 2026-10-12 16:46:05 +08:00
CI Bot 7f661c6594 refactor(viral-video): PR2-流程直线化(重试链/校验提取)+信任链公共函数+Celery装饰器+API积分结算提取
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 42s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m24s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
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 / Integration Tests (pull_request) Successful in 11m12s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m48s
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 / 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 / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
1. _step_script_generation 直线化:
   - 新增 _validate_copy_result / _call_llm_once 两个小函数
   - 重试链统一为 for 循环:fast@0.8 → fast@0.6 → pro@0.7 → fallback
   - 删除冗余 compress-retry 分支
   - 删除 _try_gen / _try_gen_stream 两个重复函数,流式/同步统一由 _call_llm_once 根据 on_delta 自动选择
   - 删除 _validate_and_normalize_script(JSON fallback 已在 PR1 废弃,XML-only 路径)

2. 信任链公共函数:
   - 新增 _collect_person_portraits / _build_sparse_trusted_urls / _preheat_trust_chain_sync / _persist_pre_trusted_images
   - _start_trust_chain_preheat 与 _step_render 现场信任链复用同一套筛选/构建逻辑,消除 ~60 行重复

3. Celery task 样板抽取装饰器 with_viral_job:
   - 5个 Celery task(pipeline/resume/style_analysis/analyze/generate_copy/render)样板代码从 ~30 行/个降到 0
   - 支持 require_status / start_heartbeat / recover_stale / mark_running_on_entry / refund_on_failure 参数
   - bind=True 自适应(Celery self 或直接 job_id 字符串调用都兼容)
   - Retry 异常正确重新 raise,其余异常统一 mark_failed_and_notify
   - 阶段3 render 失败兜底退款由装饰器 refund_on_failure=True 统一处理

4. API 层积分结算提取 _settle_viral_video_credits:
   - 支持 deduct_first / recompute / delta 三种 action,confirm_copy / retry 共用
   - 消除 ~80 行重复
   - hard_constraints/negative_prompts fallback 统一从 constants 导入(API_FALLBACK_*),避免 worker/API 漂移
   - 入队失败处加 TODO 注释:应返回 503 而非 mark_failed+200(待后续 PR 修)

验证:python ast.parse OK,worker/api import OK,248 viral 单测全通过。
净减 112 行(constants 新增 36 行;worker -267 行;api -21 行;tests -54 行)
2026-10-12 16:40:01 +08:00
CI Bot 7642ca9177 refactor(viral-video): PR5-前端P1组件拆分(StepPanel/StoryboardCard/InlineText)+三态统一+单调性守卫+polling ref模式+locked真实状态
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m46s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m22s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m4s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m16s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m10s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 4m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / 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 / Validate - Style (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 - Security (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- 抽 InlineEditTextarea 通用内联编辑组件,消除 11 处反射式 textarea/span 复制粘贴
- 抽 useStoryboardEditor hook 统一管理 editingField 与 storyboard 字段更新
- 抽 Step1UploadPanel/Step2CopyPanel/Step3VideoPanel 三大步骤面板
- 抽 StoryboardCard + StoryboardShotItem 拆分分镜卡片与单个镜头
- 抽 VoicePickerSection 独立音色选择区
- 抽 VVSpinner/EmptyBlock/ErrorRetryBar 三态统一组件
- 抽 useViralVideoMedia + useViralVideoHandlers 聚合业务 action
- useViralVideoPolling onUpdate 改为 ref 持有,避免 useCallback 依赖重启轮询
- onPollUpdate 增加终态单调性守卫(step3_done/failed 不再回退)
- onDelta full_text 空值保护,避免清空已生成的流式文本
- review 合规失败就地显示到 copyError,不跳 failed 态
- locked 状态接入真实 uiStep(step3_generating/step3_done 禁用编辑)
- selectVideoModel 联动逻辑抽 resolveModelSwitch helper
- 新增 renderMarkdown/copyResultToStoryboard/applyPollUpdate utils 避免组件间重复
- 抽 ViralVideoPage.constants.ts 常量集中
- ViralVideoPage.tsx 2939 行 → 558 行(组件每个 ≤400 行)
- tsc 全量 viral-video 0 错误
2026-10-12 16:31:59 +08:00
xiaoxia faf4573fea Merge pull request 'refactor(viral-video): PR4-前端P0清理(删MOCK/死状态+confirmViralCopy补传7字段)' (#2275) from refactor/viral-video-frontend-p0-cleanup 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 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m49s
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
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 45s
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 / Build Staging API Image (push) Successful in 56s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto 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 / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m28s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m18s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Integration Tests (push) Successful in 6m52s
CI/CD Pipeline / Validate - Style (push) Failing after 8m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Unit Tests (push) Successful in 20m17s
CI/CD Pipeline / Validate - Security (push) Successful in 24m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 31m30s
2026-10-12 16:17:15 +08:00
xiaoxia 457362a55c fix(lint): errorClassification.ts 去除 as any,改 ViralVideoStage 类型断言
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / 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 Web Image (pull_request) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m48s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m29s
AI Code Review / AI Code Review (pull_request) Successful in 6m43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m50s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 14s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m40s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m9s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 16m5s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 22m2s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 2s
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
2026-10-12 16:09:29 +08:00
CI Bot f4ea5435a6 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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 5s
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 / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker 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 / Frontend Lint (pull_request) Failing after 1m13s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
2026-10-12 08:07:34 +00:00
CI Bot 35063a691f Merge branch 'refactor/viral-video-frontend-p0-cleanup' into refactor/viral-video-frontend-p1-component-split 2026-10-12 15:47:06 +08:00
saas 941e70f95b refactor(viral-video): PR4-前端P0清理删MOCK_STORYBOARD/step3_ready/audioInst+confirmViralCopy补传参数+清空策略统一+错误分类抽取
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API 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 / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build 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 / Frontend Lint (pull_request) Failing after 2m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m18s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m20s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m11s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 5m24s
AI Code Review / AI Code Review (pull_request) Successful in 7m23s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m10s
2026-10-12 15:39:07 +08:00
56 changed files with 5819 additions and 4550 deletions
+1
View File
@@ -1195,6 +1195,7 @@ jobs:
DOUBAO_FAST_MODEL: "${{ secrets.DOUBAO_FAST_MODEL }}"
DOUBAO_TIMEOUT: "${{ secrets.DOUBAO_TIMEOUT }}"
DOUBAO_MAX_RETRIES: "${{ secrets.DOUBAO_MAX_RETRIES }}"
DITTO_EMOTION_TIMEOUT: "${{ secrets.DITTO_EMOTION_TIMEOUT }}"
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""viral_video_param_guides 表:运营可配置的人设/结构/营销目的/目标客群/语言指导文案(PR3 P2-h)。
Revision ID: 109_add_viral_video_param_guides
Revises: 108_storyboard_v4_multivoice
Create Date: 2026-10-12
表用途:
- 替代 worker 中 ~130 行硬编码 if/elif mapping;
- 5 类 guide_type: persona / structure / marketing_purpose / target_customer / language;
- 启动时由 seed_viral_video_param_guides.py 灌入代码默认值,运营可在后台改,30s TTL 缓存自动热加载。
"""
from alembic import op
revision = "109_add_viral_video_param_guides"
down_revision = "108_storyboard_v4_multivoice"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS viral_video_param_guides (
id SERIAL PRIMARY KEY,
guide_type VARCHAR(32) NOT NULL,
guide_key VARCHAR(64) NOT NULL,
guide_text TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (guide_type, guide_key)
)
""")
op.execute("""
CREATE INDEX IF NOT EXISTS ix_viral_video_param_guides_type_key
ON viral_video_param_guides (guide_type, guide_key)
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_viral_video_param_guides_type_key")
op.execute("DROP TABLE IF EXISTS viral_video_param_guides")
+208 -253
View File
@@ -2,12 +2,10 @@
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:用户确认/编辑文案后跑渲染,直到完成
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后文案+分镜一步生成+审核,暂停在 copy_generated
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染(TTS+Seedance+上传),直到完成
旧端点(兼容保留,旧前端/一键生成模式):
POST /api/v1/viral-video/generate 一键入队,前半段跑到 wait_user_confirm
POST /api/v1/viral-video/{job_id}/confirm-intent 旧的意图确认后继续渲染
意图分析已并入脚本生成一步完成,不再有独立的 intent_parsing 阶段或 wait_user_confirm 状态。
通用:
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/copy_result 编导脚本)
@@ -30,8 +28,6 @@ from app.schemas.viral_video import (
AnalyzeStyleRequest,
AnalyzeStyleResponse,
ConfirmCopyRequest,
ConfirmIntentRequest,
CreateViralVideoRequest,
CreditsFormulaBreakdown,
EstimateCreditsRequest,
EstimateCreditsResponse,
@@ -49,7 +45,17 @@ from packages.adapters.sqlalchemy_impl.viral_video_repository import (
SQLAlchemyViralVideoJobRepository,
SQLAlchemyViralVideoStyleTemplateRepository,
)
from packages.domain.points_rules import list_viral_video_models
from packages.application.viral_video.constants import (
API_FALLBACK_HARD_CONSTRAINTS,
API_FALLBACK_NEGATIVE_PROMPTS,
)
from packages.domain.points_rules import (
calculate_viral_video_credits,
calculate_viral_video_credits_with_breakdown,
list_viral_video_models,
resolve_video_dimensions,
)
from packages.domain.points_service import PointsService
from packages.domain.viral_video import ViralVideoStatus
from packages.shared.dashscope_client import get_dashscope_client
@@ -65,7 +71,7 @@ def _build_copy_result(job) -> dict | None:
"""v1.6: 返回编导分镜脚本 CopyResult 结构(给前端/Seedance 使用)。
- 若 job.copy_result 已持久化(v1.6 worker 生成),直接返回(补 final_copy 兜底)。
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表, intent_result)拼装兼容结构。
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表)拼装兼容结构。
"""
cr = getattr(job, "copy_result", None)
if isinstance(cr, dict) and cr:
@@ -79,12 +85,9 @@ def _build_copy_result(job) -> dict | None:
# 兼容 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
title = ""
if isinstance(intent, dict):
title = intent.get("suggested_title") or intent.get("intent", "") or ""
shots = []
for seg in sb:
if isinstance(seg, dict):
@@ -105,8 +108,8 @@ def _build_copy_result(job) -> dict | None:
"overview": {"theme": title, "total_duration": getattr(job, "duration", 15), "aspect_ratio": ratio},
"scene_and_lighting": "",
"shots": shots,
"hard_constraints": ["无字幕", "无水印", "人物一致性"],
"negative_prompts": ["字幕", "水印", "错误文字", "五官崩坏"],
"hard_constraints": list(API_FALLBACK_HARD_CONSTRAINTS),
"negative_prompts": list(API_FALLBACK_NEGATIVE_PROMPTS),
"voiceover_script": copy_text,
"final_copy": copy_text,
"suggested_copy": copy_text,
@@ -145,7 +148,6 @@ def _to_response(job) -> ViralVideoJobResponse:
language=getattr(job, "language", "zh-CN") or "zh-CN",
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,
pre_trusted_images=getattr(job, "pre_trusted_images", None) or None,
video_resolution=getattr(job, "video_resolution", "720p") or "720p",
@@ -168,58 +170,157 @@ def _get_style_repo(session: Session) -> SQLAlchemyViralVideoStyleTemplateReposi
return SQLAlchemyViralVideoStyleTemplateRepository(session)
# ── Endpoints ────────────────────────────────────────────────────────────
def _settle_viral_video_credits(
user_id: int,
job,
session: Session,
*,
action: str,
old_duration: int | None = None,
old_resolution: str | None = None,
old_ratio: str | None = None,
old_model: str | None = None,
new_duration: int | None = None,
new_resolution: str | None = None,
new_ratio: str | None = None,
new_model: str | None = None,
) -> dict:
"""爆款视频积分结算统一入口(confirm-copy / retry 共用)。
action 取值:
- "deduct_first" 首次扣费(confirm 时还没付过)
- "recompute" 参数变更重算:退旧扣新(confirm_copy 时传 old/new)
- "delta" 参数变更差额结算:多退少补(retry 时传 old/new)
返回 credits_info dict,包含 old_est / new_est / action / diff 等。
积分不足时 raise HTTPException(402),调用方无需再捕获。
"""
from app.config import settings as _settings
@router.post("/generate", response_model=ViralVideoJobResponse)
def create_viral_video(
request: CreateViralVideoRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""创建爆款视频任务,入队 Celery 编排器。"""
from packages.domain.viral_video import ViralVideoJob
info: dict = {"action": action, "old_est": 0.0, "new_est": 0.0, "diff": 0.0, "settled": False}
if not _settings.points_enabled:
return info
repo = _get_job_repo(session)
# 创建领域实体
job = ViralVideoJob(
user_id=authenticated_user.user.id,
images=list(request.images),
industry=request.industry,
target_customer=request.target_customer,
persona_id=request.persona_id,
viral_structure=request.viral_structure,
marketing_purpose=request.marketing_purpose,
bgm_preference=request.bgm_preference,
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 "",
video_resolution=getattr(request, "video_resolution", "720p") or "720p",
copy_result=None,
already_paid = (float(getattr(job, "credits_prepaid", 0) or 0) > 0) or (
float(getattr(job, "credits_cost", 0) or 0) > 0
)
# 持久化
repo.save(job)
if action == "deduct_first":
if already_paid:
return info
w, h = resolve_video_dimensions(
getattr(job, "video_resolution", "720p") or "720p", job.video_ratio or "9:16"
)
est = calculate_viral_video_credits(int(job.duration or 15), w, h, job.video_model or "seedance-2.5")
info["new_est"] = est
svc = PointsService()
res = svc.deduct_viral_video(user_id, est, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {est} 积分,当前余额 {balance}",
"required": est,
"balance": balance,
},
)
job.credits_prepaid = est
job.credits_transaction_id = res.get("transaction_id", "") or ""
info["settled"] = True
info["diff"] = est
return info
# 入队 Celery 任务
try:
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 任务已入队: job_id=%s user_id=%s", job.id, job.user_id)
except Exception as e:
logger.error("[爆款视频] 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
if action == "recompute":
if not already_paid:
# 等价于首次扣费
return _settle_viral_video_credits(user_id, job, session, action="deduct_first")
old_w, old_h = resolve_video_dimensions(old_resolution or "720p", old_ratio or "9:16")
old_est = calculate_viral_video_credits(
int(old_duration or 15), old_w, old_h, old_model or "seedance-2.5"
)
new_w, new_h = resolve_video_dimensions(
getattr(job, "video_resolution", "720p") or "720p", job.video_ratio or "9:16"
)
new_est = calculate_viral_video_credits(
int(job.duration or 15), new_w, new_h, job.video_model or "seedance-2.5"
)
info.update({"old_est": old_est, "new_est": new_est, "diff": new_est - old_est})
svc = PointsService()
if getattr(job, "credits_transaction_id", None):
svc.refund_points(
user_id=user_id,
amount=float(job.credits_prepaid),
source="viral_video",
db=session,
ref_id=job.credits_transaction_id,
description="confirm-copy 参数变更退还旧预扣",
)
if new_est > 0:
res = svc.deduct_viral_video(user_id, new_est, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {new_est} 积分,当前余额 {balance}",
"required": new_est,
"balance": balance,
},
)
job.credits_prepaid = new_est
job.credits_transaction_id = res.get("transaction_id", "") or ""
info["settled"] = True
return info
return _to_response(job)
if action == "delta":
eff_dur = int(new_duration if new_duration is not None else old_duration or 15)
eff_res = new_resolution if new_resolution is not None else (old_resolution or "720p")
eff_ratio = new_ratio if new_ratio is not None else (old_ratio or "9:16")
eff_model = new_model if new_model is not None else (old_model or "seedance-2.5")
w, h = resolve_video_dimensions(eff_res, eff_ratio)
new_est, _ = calculate_viral_video_credits_with_breakdown(eff_dur, w, h, eff_model)
old_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
diff = round(new_est - old_prepaid, 2)
info.update({"old_est": old_prepaid, "new_est": new_est, "diff": diff})
if abs(diff) < 0.01:
return info
svc = PointsService()
if diff > 0:
res = svc.deduct_viral_video(user_id, diff, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"重试参数变更后需补扣 {diff} 积分,余额不足(当前 {balance},需 {new_est})",
"required": new_est,
"balance": balance,
"delta": diff,
},
)
job.credits_prepaid = round(old_prepaid + diff, 2)
else:
refund = round(-diff, 2)
txn_id = getattr(job, "credits_transaction_id", "") or ""
svc.refund_points(
user_id=user_id,
amount=refund,
source="viral_video",
db=session,
ref_id=txn_id or job.id,
description="爆款视频重试参数变更退费",
)
job.credits_prepaid = round(old_prepaid - refund, 2)
info["settled"] = True
return info
raise ValueError(f"unknown action: {action}")
# ── Endpoints ────────────────────────────────────────────────────────────
@router.post("/analyze-images", response_model=ViralVideoJobResponse)
@@ -383,84 +484,27 @@ def confirm_copy(
or (request.video_model is not None and request.video_model != old_model)
)
# 积分预扣(已扣过/重试任务跳过)
from app.config import settings as _settings
if _settings.points_enabled:
already_paid = (float(getattr(job, "credits_prepaid", 0) or 0) > 0) or (
float(getattr(job, "credits_cost", 0) or 0) > 0
# 积分预扣(PR2:统一走 _settle_viral_video_credits)
if param_changed:
credits_info = _settle_viral_video_credits(
authenticated_user.user.id,
job,
session,
action="recompute",
old_duration=old_duration,
old_resolution=old_resolution,
old_ratio=old_ratio,
old_model=old_model,
)
if param_changed and already_paid:
# 参数变更:回退旧预扣,按新参数重新预扣
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
from packages.domain.points_service import PointsService
old_w, old_h = resolve_video_dimensions(old_resolution, old_ratio)
old_est = calculate_viral_video_credits(old_duration, old_w, old_h, old_model)
new_w, new_h = resolve_video_dimensions(
getattr(job, "video_resolution", "720p") or "720p",
job.video_ratio or "9:16",
)
new_est = calculate_viral_video_credits(
int(job.duration or 15), new_w, new_h, job.video_model or "seedance-2.5"
)
svc = PointsService()
# 退回旧预扣
if getattr(job, "credits_transaction_id", None):
svc.refund_points(
user_id=authenticated_user.user.id,
amount=float(job.credits_prepaid),
source="viral_video",
db=session,
ref_id=job.credits_transaction_id,
description="confirm-copy 参数变更退还旧预扣",
)
# 预扣新金额
if new_est > 0:
res = svc.deduct_viral_video(authenticated_user.user.id, new_est, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {new_est} 积分,当前余额 {balance}",
"required": new_est,
"balance": balance,
},
)
job.credits_prepaid = new_est
job.credits_transaction_id = res.get("transaction_id", "") or ""
logger.info(
"[爆款视频][confirm-copy] 参数变更,积分重算: old=%d new=%d job_id=%s", old_est, new_est, job.id
)
elif not already_paid:
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
from packages.domain.points_service import PointsService
w, h = resolve_video_dimensions(
getattr(job, "video_resolution", "720p") or "720p",
job.video_ratio or "9:16",
)
est_credits = calculate_viral_video_credits(
int(job.duration or 15), w, h, job.video_model or "seedance-2.5"
)
svc = PointsService()
res = svc.deduct_viral_video(authenticated_user.user.id, est_credits, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {est_credits} 积分,当前余额 {balance}",
"required": est_credits,
"balance": balance,
},
)
job.credits_prepaid = est_credits
job.credits_transaction_id = res.get("transaction_id", "") or ""
repo.update(job)
logger.info(
"[爆款视频][confirm-copy] 参数变更,积分重算: old=%s new=%s job_id=%s",
credits_info.get("old_est"),
credits_info.get("new_est"),
job.id,
)
else:
_settle_viral_video_credits(authenticated_user.user.id, job, session, action="deduct_first")
repo.update(job)
job.resume_from_copy_generated(edited_copy=request.edited_copy or None)
repo.update(job)
@@ -470,6 +514,7 @@ def confirm_copy(
logger.info("[爆款视频][阶段3] confirm-copy 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段3] confirm-copy 入队失败: %s", e, exc_info=True)
# TODO(PR2-followup): 入队失败应返回 HTTP 503 而非 mark_failed + 200,避免前端收到"成功"但 job 已 failed 的不一致状态
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
@@ -487,11 +532,6 @@ def estimate_credits(
profit_multiplier / model_price / width / height / fps),便于前端展示计费明细。
同时兼容前端传 model 或 video_model、resolution 或 video_resolution、ratio 或 video_ratio。
"""
from packages.domain.points_rules import (
calculate_viral_video_credits_with_breakdown,
resolve_video_dimensions,
)
model = (request.model or "").strip() or "seedance-2.5"
resolution = (request.resolution or "").strip() or "720p"
ratio = (request.ratio or "").strip() or "9:16"
@@ -623,25 +663,7 @@ def retry_viral_video_job(
or (new_model is not None and new_model != old_model)
)
from app.config import settings as _settings
need_points_settle = False
new_est = 0.0
if _settings.points_enabled and param_changed:
from packages.domain.points_rules import (
calculate_viral_video_credits_with_breakdown,
resolve_video_dimensions,
)
eff_dur = int(new_duration if new_duration is not None else old_duration)
eff_res = new_resolution if new_resolution is not None else old_resolution
eff_ratio = new_ratio if new_ratio is not None else old_ratio
eff_model = new_model if new_model is not None else (old_model or "seedance-2.5")
w, h = resolve_video_dimensions(eff_res, eff_ratio)
new_est, _ = calculate_viral_video_credits_with_breakdown(eff_dur, w, h, eff_model or "seedance-2.5")
need_points_settle = True
# 写入新参数(即使不开 points 也要允许用户重试时改参数)
# 写入新参数(即使不开 points 也要允许用户重试时改参数;delta 结算依赖这些新值)
if new_duration is not None:
job.duration = max(5, min(30, int(new_duration)))
if new_resolution is not None:
@@ -651,55 +673,29 @@ def retry_viral_video_job(
if new_model is not None:
job.video_model = new_model
if need_points_settle:
from packages.domain.points_service import PointsService
old_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
svc = PointsService()
diff = round(new_est - old_prepaid, 2)
if abs(diff) >= 0.01:
if diff > 0:
# 新预扣更多:补扣差额
res = svc.deduct_viral_video(authenticated_user.user.id, diff, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"重试参数变更后需补扣 {diff} 积分,余额不足(当前 {balance},需 {new_est})",
"required": new_est,
"balance": balance,
"delta": diff,
},
)
job.credits_prepaid = round(old_prepaid + diff, 2)
logger.info(
"[爆款视频][retry] 补扣差额 job_id=%s diff=%.2f new_prepaid=%.2f",
job.id,
diff,
job.credits_prepaid,
)
else:
# 新预扣更少:退还差额
refund = round(-diff, 2)
txn_id = getattr(job, "credits_transaction_id", "") or ""
svc.refund_points(
user_id=authenticated_user.user.id,
amount=refund,
source="viral_video",
db=session,
ref_id=txn_id or job.id,
description="爆款视频重试参数变更退费",
)
job.credits_prepaid = round(old_prepaid - refund, 2)
logger.info(
"[爆款视频][retry] 退还差额 job_id=%s refund=%.2f new_prepaid=%.2f",
job.id,
refund,
job.credits_prepaid,
)
# 差额为 0 则不调整
# PR2:统一走 _settle_viral_video_credits(delta 多退少补)
if param_changed:
credits_info = _settle_viral_video_credits(
authenticated_user.user.id,
job,
session,
action="delta",
old_duration=old_duration,
old_resolution=old_resolution,
old_ratio=old_ratio,
old_model=old_model,
new_duration=new_duration,
new_resolution=new_resolution,
new_ratio=new_ratio,
new_model=new_model,
)
if credits_info.get("settled"):
logger.info(
"[爆款视频][retry] 积分差额结算 job_id=%s diff=%.2f new_prepaid=%.2f",
job.id,
credits_info["diff"],
job.credits_prepaid,
)
# 重置状态(从阶段1重新开始,清空上一次产物避免脏数据)
job.retry_count += 1
@@ -711,7 +707,6 @@ def retry_viral_video_job(
job.phase_message = ""
job.heartbeat_at = None
# 重试时清空上次产物(参考 resume_from_image_analyzed 逻辑)
job.intent_result = None
job.copy_result = None
job.storyboard = None
job.generated_copy_text = ""
@@ -720,8 +715,7 @@ def retry_viral_video_job(
job.credits_cost = 0.0
repo.update(job)
# 重新入队:从图片分析阶段1重新跑(旧 run_viral_video_pipeline 只跑到 wait_user_confirm 就停,
# 会导致前端永远转圈或文案确认页卡死后 confirm-copy 返回 409)
# 重新入队:从图片分析阶段1重新跑
try:
celery_app.send_task("worker.run_viral_video_analyze", args=[job.id])
logger.info(
@@ -733,49 +727,13 @@ def retry_viral_video_job(
)
except Exception as e:
logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True)
# TODO(PR2-followup): 入队失败应返回 HTTP 503 而非 mark_failed + 200
job.mark_failed(f"重试入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/confirm-intent", response_model=ViralVideoJobResponse)
def confirm_intent(
job_id: str,
request: ConfirmIntentRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""用户确认/修改 AI 生成的意图文案,恢复流水线。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status != ViralVideoStatus.WAIT_USER_CONFIRM:
raise HTTPException(status_code=409, detail="任务当前不在等待确认状态")
# 更新文案
if request.confirmed_copy:
job.user_copy_text = request.confirmed_copy
# 恢复流水线
job.resume_from_confirm()
repo.update(job)
# 从断点恢复 Celery 任务
try:
celery_app.send_task("worker.resume_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 意图确认,恢复流水线: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频] 恢复流水线失败: %s", e, exc_info=True)
job.mark_failed(f"恢复流水线失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/analyze-style", response_model=AnalyzeStyleResponse)
def analyze_style(
job_id: str,
@@ -1042,7 +1000,6 @@ _STATUS_STAGE = {
"running": "",
"image_analyzed": "image_analysis",
"copy_generated": "review",
"wait_user_confirm": "intent_parsing",
"completed": "uploading",
"failed": "",
"cancelled": "",
@@ -1053,7 +1010,6 @@ _STATUS_PROGRESS = {
"running": 5.0,
"image_analyzed": 15.0,
"copy_generated": 70.0,
"wait_user_confirm": 35.0,
"completed": 100.0,
"failed": 0.0,
"cancelled": 0.0,
@@ -1064,7 +1020,6 @@ _STATUS_MESSAGE = {
"running": "任务执行中",
"image_analyzed": "图片分析完成,等待填写营销参数",
"copy_generated": "文案与分镜已生成,等待确认文案",
"wait_user_confirm": "等待用户确认意图文案",
"completed": "视频生成完成",
"failed": "任务失败",
"cancelled": "任务已取消",
-50
View File
@@ -13,7 +13,6 @@ VALID_STYLE_STRENGTHS = ("light", "medium", "strict")
VALID_STAGES = (
"image_analysis",
"video_analysis",
"intent_parsing",
"script_generation",
"review",
"tts",
@@ -68,47 +67,6 @@ class CopyResult(BaseModel):
# -- Request Schemas --
class CreateViralVideoRequest(BaseModel):
"""旧接口:一键创建(保留兼容)。"""
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 = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
video_resolution: str = "720p"
@field_validator("fusion_level")
@classmethod
def _v_fl(cls, v: str) -> str:
if v == "full_ai":
return "ai_full"
if v not in VALID_FUSION_LEVELS:
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
return v
@field_validator("style_strength")
@classmethod
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:创建任务 + 图片/视频分析。"""
@@ -179,13 +137,6 @@ class ConfirmCopyRequest(BaseModel):
language: str | None = Field(default=None, description="用户选定的语言(confirm时可选)")
class ConfirmIntentRequest(BaseModel):
"""旧 confirm-intent(兼容)。"""
confirmed_copy: str = ""
adjustments: str = ""
class AnalyzeStyleRequest(BaseModel):
reference_video_url: str = Field(..., description="参考视频 URL")
style_template_id: str = ""
@@ -231,7 +182,6 @@ class ViralVideoJobResponse(BaseModel):
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
intent_result: dict | None = None
result_video_url: str = ""
pre_trusted_images: list[str] | None = None
video_resolution: str = "720p"
-16
View File
@@ -1,6 +1,5 @@
import apiClient from "@/api/client"
import type {
GenerateViralVideoRequest,
HistoryResponse,
StyleTemplate,
ViralVideoJob,
@@ -13,26 +12,11 @@ import type {
ViralVideoModelsResponse,
} from "./types"
/** 创建爆款视频任务 */
export function generateViralVideo(payload: GenerateViralVideoRequest) {
return apiClient.post<ViralVideoJob>("/viral-video/generate", payload).then((r) => r.data)
}
/** 查询单个任务 */
export function getViralVideoJob(id: string) {
return apiClient.get<ViralVideoJob>(`/viral-video/${id}`).then((r) => r.data)
}
/** 用户确认/修改 AI 理解的意图后继续 */
export function confirmViralVideoIntent(
id: string,
payload: { confirmed_copy?: string; edits?: Record<string, unknown> },
) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/confirm-intent`, payload)
.then((r) => r.data)
}
/** 重试失败任务 */
export function retryViralVideo(id: string) {
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/retry`).then((r) => r.data)
+15 -54
View File
@@ -21,23 +21,15 @@ export const VALID_RATIOS = ["9:16", "16:9", "1:1"] as const
export type VideoRatio = (typeof VALID_RATIOS)[number]
export type ViralVideoStatus =
| "pending"
| "running"
| "wait_user_confirm"
| "image_analyzed"
| "copy_generated"
| "completed"
| "failed"
| "cancelled"
"pending" | "running" | "image_analyzed" | "copy_generated" | "completed" | "failed" | "cancelled"
/**
* v1.6 后端流水线阶段。单次 Seedance 出片版:
* image_analysis → video_analysis(可选) → intent_parsing → script_generation → review → tts → rendering → uploading
* image_analysis → video_analysis(可选) → script_generation(含意图理解) → review → tts → rendering → uploading
*/
export type ViralVideoStage =
| "image_analysis"
| "video_analysis"
| "intent_parsing"
| "script_generation"
| "review"
| "tts"
@@ -47,7 +39,7 @@ export type ViralVideoStage =
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
/** 编导脚本阶段:属于「生成文案」按钮的范围 */
const COPY_STAGES = new Set<ViralVideoStage>(["intent_parsing", "script_generation", "review"])
const COPY_STAGES = new Set<ViralVideoStage>(["script_generation", "review"])
/** 视频生成阶段:属于「开始生成视频」按钮的范围(v1.6: TTS+单次Seedance+上传) */
const VIDEO_STAGES = new Set<ViralVideoStage>(["tts", "rendering", "uploading"])
@@ -161,22 +153,6 @@ export interface StyleTemplate {
tags?: string[]
}
export interface IntentResult {
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
}
export interface ViralVideoJob {
id: string
status: ViralVideoStatus
@@ -194,8 +170,6 @@ export interface ViralVideoJob {
voice_mode?: "global" | "per_video"
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
bgm_preference?: string
intent_result?: IntentResult
intent_text?: string
/** v1.6 编导分镜脚本(核心产物) */
copy_result?: CopyResult
/** 向后兼容:= copy_result.shots */
@@ -225,31 +199,6 @@ export interface ViralVideoJob {
updated_at?: string
}
export interface GenerateViralVideoRequest {
images: string[]
reference_video_url?: string
douyin_url?: string
style_strength?: StyleStrength
style_template_id?: string
user_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
bgm_preference?: string
industry?: string
target_customer?: string
language?: string
persona_id?: string
viral_structure?: string
marketing_purpose?: string
/** 视频时长(5-30秒,默认15) */
duration?: number
video_model?: string
video_ratio?: string
/** 三步拆分:step 控制后端执行到哪一步暂停 */
step?: "analyze" | "generate_copy" | "generate_video"
}
export interface HistoryResponse {
items: ViralVideoJob[]
total: number
@@ -325,6 +274,18 @@ export interface ConfirmCopyRequest {
edited_copy?: string
/** 视频模型 key,覆盖默认 */
video_model?: string
/** 分辨率,如 480p/720p/1080p */
video_resolution?: string
/** 画面比例,如 9:16/16:9/1:1 */
video_ratio?: string
/** 成片时长秒数 */
duration?: number
/** 音色 ID(CosyVoice voice_id 或 preset id) */
voice_id?: string
/** 音色来源:preset/library/upload/clone/my_voice */
voice_source?: string
/** 口播语言 */
language?: string
}
/** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */
@@ -0,0 +1,128 @@
import type { ViralVideoModel } from "@/api/viral-video/types"
export const LANGUAGES = ["中文(普通话)", "粤语", "英语", "日语", "韩语"]
export const TARGET_CUSTOMERS = [
"小镇青年",
"小镇中老年",
"Z世代",
"精致妈妈",
"新锐白领",
"资深中产",
"都市蓝领",
"都市银发",
]
export const PERSONAS = [
"通用个人IP",
"老板型IP",
"专家型IP",
"顾问型IP",
"创始人IP",
"创业者IP",
"从业者经验派",
"避坑顾问型",
"知识科普型",
"测评种草型",
]
export const STRUCTURES = [
"反差破局+亮明观点+还原现状",
"痛点场景植入+给出通关秘籍",
"反常识开场+扎心现状+制造焦虑",
"核心难题+化解焦虑+描绘愿景",
"痛点前置+前后对比+给出期许",
"悬念拉满+揭露危害+明确目的",
"情绪共鸣+勾起好奇+拔高价值",
"颠覆认知+现状拆解+赋予价值",
"结果先行+消除顾虑+保留悬念",
"真实困境+认知反转+传授秘籍",
"反差事实+案例佐证+戳破真相",
"预埋悬念+标杆案例+引出核心",
"危机渲染+放大痛点+输出干货",
"逆袭开场+成功诀窍+低价引流",
"反常识观点+分析危害+描述现状",
"正反对比+解析原因+简易方法",
"真实场景+内心刻画+共情引导+展望未来",
"连续否定+代入感受+打破认知+精准戳痛",
"揭秘行业+类比施压+放大焦虑+精准锁客",
]
export const PURPOSES = [
"获客引流",
"账号涨粉",
"活动通知",
"口播带货",
"促销转化",
"场景种草",
"痛点解决",
"功能演示",
"门店发现",
"到店实录",
"招牌体验",
"同城团购",
"品牌主张",
"品牌故事",
"生活方式",
"创意概念",
"对话短剧",
"反转短剧",
"悬念短剧",
"情绪短片",
]
export const RATIOS = [
{ v: "9:16", label: "9:16 竖屏(抖音/视频号)" },
{ v: "16:9", label: "16:9 横屏(B站/YouTube)" },
{ v: "1:1", label: "1:1 方形(小红书)" },
]
export const DURATIONS = Array.from({ length: 16 }, (_, i) => 15 + i)
/** 兜底模型列表(接口未返回时使用) */
export const FALLBACK_VIDEO_MODELS: ViralVideoModel[] = [
{
key: "seedance-2.5",
display_name: "Seedance 2.5 — 最新最强",
supports_audio: true,
supported_resolutions: ["480p", "720p", "1080p"],
max_duration: 30,
is_default: true,
},
{
key: "seedance-2.0",
display_name: "Seedance 2.0 — 正式首选",
supports_audio: true,
supported_resolutions: ["480p", "720p", "1080p"],
max_duration: 15,
},
{
key: "seedance-2.0-fast",
display_name: "Seedance 2.0 Fast — 快速测试",
supports_audio: true,
supported_resolutions: ["480p", "720p"],
max_duration: 15,
},
{
key: "seedance-2.0-mini",
display_name: "Seedance 2.0 Mini — 低成本",
supports_audio: true,
supported_resolutions: ["480p", "720p"],
max_duration: 15,
},
{
key: "wan-3.0",
display_name: "Wan 3.0 — 通义万相全能",
supports_audio: true,
supported_resolutions: ["480p", "720p", "1080p"],
max_duration: 15,
},
]
export const RESOLUTION_ORDER = ["480p", "720p", "1080p", "4k"]
export const QUALITY_OPTIONS = [
{ v: "480p", label: "480p(快速)" },
{ v: "720p", label: "720p(清晰)" },
{ v: "1080p", label: "1080p(高清)" },
]
export const UPSCALE_OPTIONS = [
{ v: "default", label: "默认" },
{ v: "sd", label: "普清" },
{ v: "hd", label: "高清" },
{ v: "uhd", label: "超清" },
{ v: "omnipotent", label: "全能" },
]
export const VOICE_TIPS =
"支持 MP3/WAV/M4A/AAC/OGG 格式,最大 10MB,时长 ≤30 秒。建议清晰人声、无背景音乐、环境安静;录音请保持距麦克风 15-20cm,音量适中。"
+91 -185
View File
@@ -138,27 +138,6 @@
border-radius: 12px;
overflow: hidden;
}
.vv-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid #f0f0f5;
gap: 8px;
}
.vv-section.collapsed .vv-section-head {
border-bottom: none;
}
.vv-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.vv-step-badge {
width: 22px;
height: 22px;
@@ -310,11 +289,6 @@
/* antd Select dropdown light */
.vv-page .ant-select-dropdown,
.vv-select-dropdown {
background: #fff !important;
border-radius: 8px !important;
padding: 4px !important;
}
.vv-page .ant-select-item {
color: #1f2937 !important;
border-radius: 6px !important;
@@ -655,10 +629,6 @@
margin-left: auto;
}
.vv-douyin-input {
margin-bottom: 8px;
}
.vv-ref-video-box {
margin-top: 8px;
}
@@ -669,44 +639,6 @@
max-height: 200px;
}
.vv-audio-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.vv-audio-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 14px 8px;
background: #fff;
border: 1px solid #e0e0e8;
border-radius: 8px;
color: #6b7280;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.vv-audio-cell:hover:not(:disabled) {
border-color: #7c3aed;
color: #7c3aed;
}
.vv-audio-cell.selected,
.vv-audio-cell.active {
background: #f5f0ff;
border-color: #7c3aed;
color: #7c3aed;
}
.vv-audio-cell:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.vv-audio-ic {
font-size: 20px;
}
.vv-selected-audio {
display: flex;
align-items: center;
@@ -857,12 +789,6 @@
color: #6b7280;
flex-shrink: 0;
}
.vv-recog-v {
color: #1f2937;
}
.vv-recog-meta {
color: #9ca3af;
}
/* progress mini */
.vv-progress-mini-inline {
@@ -1002,11 +928,6 @@
.vv-form-row {
margin-bottom: 10px;
}
.vv-form-hint {
font-size: 12px;
color: #9ca3af;
line-height: 1.4;
}
@media (max-width: 500px) {
.vv-form-grid {
grid-template-columns: 1fr;
@@ -1026,29 +947,6 @@
min-height: 260px;
overflow: hidden;
}
.vv-copy-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.vv-copy-title {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.vv-copy-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 400;
}
.vv-copy-tag-preview {
background: #fef3c7;
color: #b45309;
border: 1px solid #fcd34d;
margin-left: auto;
}
.vv-copy-loading {
display: flex;
@@ -1080,15 +978,6 @@
color: #6b7280;
}
.vv-copy-locked {
background: #fafafe;
border-color: #d8cafc;
}
.vv-copy-sub {
font-size: 12px;
color: #6b7280;
}
/* ── Storyboard (linear doc style) ── */
.vv-storyboard {
display: flex;
@@ -1789,84 +1678,10 @@
}
/* intent (keyword chips) - if used */
.vv-intent {
margin-top: 10px;
}
.vv-intent-title {
font-size: 12px;
color: #6b7280;
margin-bottom: 6px;
}
.vv-intent-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.vv-chip {
padding: 4px 10px;
background: #fff;
border: 1px solid #e0e0e8;
border-radius: 14px;
font-size: 12px;
color: #374151;
cursor: pointer;
}
.vv-chip.picked {
background: #f5f0ff;
color: #7c3aed;
border-color: #7c3aed;
}
/* misc helpers */
.vv-k {
color: #6b7280;
}
.vv-v {
color: #1f2937;
}
.vv-hint-yellow {
background: #fef3c7;
border: 1px solid #fcd34d;
color: #b45309;
font-size: 12px;
padding: 8px;
border-radius: 6px;
line-height: 1.6;
margin-top: 8px;
}
.vv-hint-gray {
color: #9ca3af;
font-size: 12px;
}
/* Segmented tabs (legacy) */
.vv-seg-tabs {
display: inline-flex;
background: #f5f6fa;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 3px;
gap: 2px;
}
.vv-seg-tab {
padding: 6px 14px;
border-radius: 6px;
background: #fff;
border: none;
color: #6b7280;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.vv-seg-tab:hover {
color: #374151;
background: #fff;
}
.vv-seg-tab.active {
background: #f3f0ff;
color: #7c3aed;
font-weight: 500;
}
/* ── v1.6 我的音色(默认主路径) ── */
.vv-voice-section {
@@ -2272,3 +2087,94 @@
.vv-file-list {
max-height: none;
}
/* ── P2 新增 utility / 组件类 ── */
/* 短剧模式提示条(放在 Step2 区域就地显示) */
.vv-shortdrama-hint {
padding: 8px 12px;
margin-bottom: 8px;
background-color: #fff7e6;
border: 1px solid #ffd591;
border-radius: 4px;
font-size: 13px;
color: #d46b08;
}
/* speaker 标签(主角 / 配角) */
.vv-sb-speaker-tag {
display: inline-block;
padding: 2px 8px;
margin: 0 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
vertical-align: middle;
}
.vv-sb-speaker-tag-main {
background-color: #e6f7ff;
color: #1890ff;
}
.vv-sb-speaker-tag-guest {
background-color: #fff7e6;
color: #fa8c16;
}
.vv-sb-speaker-main {
color: #1890ff;
}
.vv-sb-speaker-guest {
color: #fa8c16;
}
/* 间距 / 排版 helpers */
.vv-mt-4 {
margin-top: 4px;
}
.vv-mt-6 {
margin-top: 6px;
}
.vv-mt-8 {
margin-top: 8px;
}
.vv-mb-6 {
margin-bottom: 6px;
}
.vv-mr-6 {
margin-right: 6px;
}
.vv-fs-12 {
font-size: 12px;
}
.vv-fs-13 {
font-size: 13px;
}
.vv-fs-22 {
font-size: 22px;
}
.vv-p-12 {
padding: 12px;
}
.vv-text-center {
text-align: center;
}
.vv-flex-1 {
flex: 1;
}
.vv-w-170 {
width: 170px;
flex: 0 0 auto;
}
.vv-inline-spinner {
margin-right: 6px;
display: inline-block;
vertical-align: middle;
}
.vv-progress-bar-sm {
margin: 6px 0 4px;
}
.vv-loading-ic {
font-size: 36px;
color: #7c3aed;
}
File diff suppressed because it is too large Load Diff
@@ -152,8 +152,7 @@ export default function AssetPickerModal({
<div className="vv-modal-body">
<div className="vv-asset-search">
<select
className="vv-input"
style={{ width: 170, flex: "0 0 auto" }}
className="vv-input vv-w-170"
value={libraryId}
onChange={(e) => setLibraryId(e.target.value)}
disabled={loadingLibs || libraries.length === 0}
@@ -201,8 +200,8 @@ export default function AssetPickerModal({
</div>
{kind === "voice" ? (
<>
<div style={{ marginTop: 8, fontSize: 13 }}>暂无配音素材</div>
<div style={{ marginTop: 4, fontSize: 12, color: "#9ca3af" }}>
<div className="vv-mt-8 vv-fs-13">暂无配音素材</div>
<div className="vv-muted vv-mt-4 vv-fs-12">
请先在「配音/我的音色」中上传音频文件,或在素材库管理中添加
</div>
</>
@@ -0,0 +1,33 @@
import React from "react"
export interface EmptyBlockProps {
icon?: React.ReactNode
title?: string
desc?: React.ReactNode
action?: React.ReactNode
className?: string
style?: React.CSSProperties
}
/**
* 统一空态组件:icon + title + desc + action,避免散落的 vv-muted 空文案
*/
export const EmptyBlock: React.FC<EmptyBlockProps> = ({
icon = "🎬",
title,
desc,
action,
className,
style,
}) => {
return (
<div className={`vv-empty-block ${className || ""}`} style={style}>
<div className="vv-empty-icon">{icon}</div>
{title && <div className="vv-empty-title">{title}</div>}
{desc && <div className="vv-empty-desc vv-muted">{desc}</div>}
{action && <div className="vv-empty-action">{action}</div>}
</div>
)
}
export default EmptyBlock
@@ -0,0 +1,43 @@
import React from "react"
import { WarningFilled, ReloadOutlined } from "@ant-design/icons"
export interface ErrorRetryBarProps {
label?: string
message: string
onRetry?: () => void
retryText?: string
className?: string
style?: React.CSSProperties
}
/**
* 统一错误条:警告图标 + 标签前缀 + 错误消息 + 可选重试按钮
* 替换主页面里散落的 .vv-error 块
*/
export const ErrorRetryBar: React.FC<ErrorRetryBarProps> = ({
label,
message,
onRetry,
retryText = "重试",
className,
style,
}) => {
return (
<div className={`vv-error ${className || ""}`} style={style}>
<WarningFilled style={{ marginRight: 6 }} />
{label ? `${label}:` : ""}
{message}
{onRetry && (
<button
className="vv-btn vv-btn-warn vv-btn-sm"
style={{ marginLeft: 10 }}
onClick={onRetry}
>
<ReloadOutlined /> {retryText}
</button>
)}
</div>
)
}
export default ErrorRetryBar
@@ -0,0 +1,111 @@
import React, { useState, useRef, useEffect, useCallback } from "react"
export interface InlineTextProps {
value: string
onChange?: (v: string) => void // 受控模式
onCommit: (v: string) => void // blur/Enter 提交
onCancel?: () => void
multiline?: boolean
textareaClass?: string
textClass?: string
placeholder?: string
editing: boolean
onStartEdit: () => void
rows?: number
maxLength?: number
disabled?: boolean
}
/**
* 通用"点文字→textarea→blur/Enter保存→Esc取消"内联编辑组件
* 替换 ViralVideoPage 里 11 处反射式 textarea/span 复制粘贴
*/
export const InlineText: React.FC<InlineTextProps> = ({
value,
onChange,
onCommit,
onCancel,
multiline,
textareaClass,
textClass,
placeholder,
editing,
onStartEdit,
rows = 2,
maxLength,
disabled,
}) => {
const taRef = useRef<HTMLTextAreaElement>(null)
const [local, setLocal] = useState(value)
useEffect(() => {
setLocal(value)
}, [value])
useEffect(() => {
if (editing && taRef.current) {
taRef.current.focus()
taRef.current.select()
}
}, [editing])
const commit = useCallback(() => {
const v = (onChange ? value : local).trim()
const cur = (value ?? "").trim()
if (v !== cur) onCommit(v)
else onCancel?.()
}, [local, value, onCommit, onCancel, onChange])
const handleKey = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
onCancel?.()
}
if (!multiline && e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
commit()
}
},
[multiline, commit, onCancel],
)
if (editing) {
return (
<textarea
ref={taRef}
className={textareaClass}
value={onChange ? value : local}
onChange={(e) => (onChange ? onChange(e.target.value) : setLocal(e.target.value))}
onBlur={commit}
onKeyDown={handleKey}
rows={rows}
maxLength={maxLength}
placeholder={placeholder}
/>
)
}
const displayText = value || placeholder || "点击编辑"
const isPlaceholder = !value
return (
<span
className={`${textClass || ""} ${isPlaceholder ? "vv-inline-placeholder" : ""} ${
disabled ? "vv-inline-disabled" : ""
}`}
onClick={() => {
if (!disabled) onStartEdit()
}}
style={
isPlaceholder
? { color: "#9ca3af", cursor: disabled ? "not-allowed" : "text" }
: { cursor: disabled ? "not-allowed" : "text" }
}
>
{displayText}
</span>
)
}
export default InlineText
@@ -106,10 +106,10 @@ const CATEGORY_LABELS: Record<string, string> = {
const GENDER_LABEL = (v: PresetVoice) => {
if (v.gender_label) return v.gender_label
const g = v.gender
if (g === "female") return "女声·女声"
if (g === "male") return "男声·男声"
if (g === "child") return "童声·童声"
return "性别未标注·其他"
if (g === "female") return "女声"
if (g === "male") return "男声"
if (g === "child") return "童声"
return "其他"
}
const AVATAR_BG = (gender?: string) => {
@@ -303,7 +303,7 @@ const PresetVoicePickerModal: React.FC<Props> = ({
{v.avatar_url ? (
<img src={v.avatar_url} alt={v.name} />
) : (
<UserOutlined style={{ fontSize: 22 }} />
<UserOutlined className="vv-fs-22" />
)}
</div>
<div className="vv-voice-card-name" title={v.name}>
@@ -0,0 +1,315 @@
import React, { useMemo } from "react"
import {
UploadOutlined,
FolderOpenOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
CloseOutlined,
} from "@ant-design/icons"
import { VVSpinner } from "./VVSpinner"
import { renderMarkdown } from "../utils/renderMarkdown"
import { CheckCircleFilled } from "@ant-design/icons"
import { VoicePickerSection } from "./VoicePickerSection"
import type { UploadedImage, RefVideo, RefAudio, VoicePanel, ImageAnalysisResult } from "../types"
import type { ImageProductAnalysis, StyleStrength } from "@/api/viral-video/types"
import { STYLE_STRENGTHS } from "@/api/viral-video/types"
import type { VoiceClone } from "@/api/voice-clone"
import type { AssetItem } from "@/api/assets"
export interface Step1UploadPanelProps {
images: UploadedImage[]
refVideo: RefVideo
voicePanel: VoicePanel
refAudio: RefAudio
voiceTipsOpen: boolean
myVoices: VoiceClone[]
myVoicesLoading: boolean
voiceAssets: { id: string; name: string; url: string; desc?: string }[]
playingVoiceId: string | null
styleStrength: StyleStrength
imageAnalysis: ImageAnalysisResult | null
isAnalyzing: boolean
analysisError?: string
canAnalyze: boolean
analyzeBtnText: React.ReactNode
// callbacks - images
onPickLocalImages: () => void
onPickAssetImages: () => void
onRemoveImage: (localId: string) => void
onMoveImage: (idx: number, dir: -1 | 1) => void
onClearImages: () => void
onImagesSelected: (files: FileList) => void
onAnalyze: () => void
// callbacks - ref video
onPickLocalVideo: () => void
onVideoSelected: (file: File) => void
onRemoveRefVideo: () => void
onStyleStrengthChange: (v: StyleStrength) => void
// callbacks - voice (delegated to VoicePickerSection)
onPickLocalAudio: () => void
onPickAssetAudio: () => void
onRemoveRefAudio: () => void
onToggleVoicePanel: (p: VoicePanel) => void
onSelectMyVoice: (v: VoiceClone) => void
onOpenCloneModal: () => void
onSelectVoiceAsset: (a: AssetItem) => void
onToggleVoicePlay: (v: { id: string; url: string }) => void
onVoiceSelected: (file: File) => void
onToggleVoiceTips: () => void
// refs
imgInputRef: React.Ref<HTMLInputElement>
videoInputRef: React.Ref<HTMLInputElement>
voiceInputRef: React.Ref<HTMLInputElement>
}
const fmtSize = (bytes: number | undefined) => {
if (!bytes || bytes <= 0) return "--"
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
function RecognitionBlock({
imageAnalysis,
isAnalyzing,
}: {
imageAnalysis: ImageAnalysisResult | null
isAnalyzing: boolean
}) {
const images: ImageProductAnalysis[] = useMemo(() => {
return (
(imageAnalysis?.images as ImageProductAnalysis[] | undefined) ||
(imageAnalysis?.products as ImageProductAnalysis[] | undefined) ||
[]
)
}, [imageAnalysis])
if (isAnalyzing) {
return (
<div className="vv-recog">
<div className="vv-recog-title">
<VVSpinner color="#7c3aed" className="vv-inline-spinner" />
识别描述汇览
</div>
<div className="vv-muted">AI 正在识别画面…</div>
</div>
)
}
if (images.length === 0) return null
return (
<div className="vv-recog">
<div className="vv-recog-title">
<CheckCircleFilled style={{ color: "#10b981" }} />
识别描述汇览
</div>
{images.map((p, i) => {
const meta = [p.name || "未识别", p.brand, p.category].filter(Boolean)
return (
<div key={i} className="vv-recog-item vv-recog-md">
<div className="vv-recog-line">
<span className="vv-recog-k">图片{i + 1}:</span>
<span>{meta.join(" · ")}</span>
</div>
{p.summary_markdown ? (
<div
className="vv-md-body"
dangerouslySetInnerHTML={{ __html: renderMarkdown(p.summary_markdown) }}
/>
) : (
<div className="vv-muted">(暂无叙述描述)</div>
)}
</div>
)
})}
</div>
)
}
export const Step1UploadPanel: React.FC<Step1UploadPanelProps> = (p) => {
return (
<div className="vv-section">
<div className="vv-step1-head">
<span className="vv-step-badge">1</span>
<span className="vv-step1-title">STEP 1 上传素材并分析图片</span>
</div>
<div className="vv-section-body vv-section-body-scroll">
<div className="vv-scroll-inner">
{/* 图片按钮 */}
<div className="vv-action-row">
<button className="vv-action-btn" onClick={p.onPickLocalImages}>
<UploadOutlined /> 本地上传
</button>
<button className="vv-action-btn" onClick={p.onPickAssetImages}>
<FolderOpenOutlined /> 从素材库选择图片
</button>
</div>
<input
ref={p.imgInputRef}
type="file"
multiple
accept="image/*"
hidden
onChange={(e) => {
if (e.target.files) p.onImagesSelected(e.target.files)
e.target.value = ""
}}
/>
<div className="vv-count-row">
<span className="vv-muted">
已选 <strong style={{ color: "#111827" }}>{p.images.length}</strong>/9
</span>
{p.images.length > 0 && (
<button className="vv-link-btn" onClick={p.onClearImages}>
清空图片
</button>
)}
</div>
{p.images.length > 0 && (
<div className="vv-file-list">
{p.images.map((im, i) => (
<div key={im.localId} className="vv-file-item">
<div className="vv-file-thumb">
<span className="vv-img-badge">{i + 1}</span>
<img src={im.ossUrl || im.url} alt={im.name} />
</div>
<div className="vv-file-info">
<div className="vv-file-name" title={im.name}>
{im.name}
</div>
<div className="vv-file-sub">
{im.uploading ? (
<>
<span className="vv-progress-mini-inline" />
上传中 {im.pct}%
</>
) : (
<>{fmtSize(im.size)}</>
)}
</div>
</div>
<div className="vv-file-actions">
<button
className="vv-icon-btn"
onClick={() => p.onMoveImage(i, -1)}
disabled={i === 0 || im.uploading}
title="上移"
>
<ArrowUpOutlined />
</button>
<button
className="vv-icon-btn"
onClick={() => p.onMoveImage(i, 1)}
disabled={i === p.images.length - 1 || im.uploading}
title="下移"
>
<ArrowDownOutlined />
</button>
<button
className="vv-icon-btn vv-icon-btn-danger"
onClick={() => p.onRemoveImage(im.localId)}
disabled={!!im.uploading}
title="删除"
>
<CloseOutlined />
</button>
</div>
</div>
))}
</div>
)}
{/* 参考视频 */}
<div className="vv-subblock">
<div className="vv-subblock-head">
<span className="vv-subblock-title">参考视频</span>
<span className="vv-opt-tag">选填</span>
<span className="vv-subblock-hint">最大 200MB · 最长 120 秒</span>
</div>
<div className="vv-action-row">
<button className="vv-action-btn" onClick={p.onPickLocalVideo}>
<UploadOutlined /> 本地上传视频
</button>
</div>
<input
ref={p.videoInputRef}
type="file"
accept="video/*"
hidden
onChange={(e) => {
if (e.target.files?.[0]) p.onVideoSelected(e.target.files[0])
e.target.value = ""
}}
/>
{p.refVideo && (
<div className="vv-ref-video-box">
<video
src={p.refVideo.ossUrl || p.refVideo.url}
controls
className="vv-ref-video-el"
/>
<div className="vv-file-info vv-mt-6">
<div className="vv-file-name">{p.refVideo.name}</div>
{p.refVideo.uploading && (
<div className="vv-progress-bar vv-progress-bar-sm">
<div className="vv-progress-fill" style={{ width: `${p.refVideo.pct}%` }} />
</div>
)}
</div>
<button className="vv-link-btn" onClick={p.onRemoveRefVideo}>
删除重传
</button>
{p.refVideo.ossUrl && (
<div className="vv-mt-8">
<div className="vv-label vv-mb-6">风格借鉴强度</div>
<div className="vv-pill-row">
{STYLE_STRENGTHS.map((s) => (
<button
key={s.value}
className={`vv-pill ${p.styleStrength === s.value ? "active" : ""}`}
onClick={() => p.onStyleStrengthChange(s.value)}
>
{s.label}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
<VoicePickerSection
refAudio={p.refAudio}
voicePanel={p.voicePanel}
voiceTipsOpen={p.voiceTipsOpen}
myVoices={p.myVoices}
myVoicesLoading={p.myVoicesLoading}
voiceAssets={p.voiceAssets}
playingVoiceId={p.playingVoiceId}
voiceInputRef={p.voiceInputRef}
onOpenCloneModal={p.onOpenCloneModal}
onToggleVoicePanel={p.onToggleVoicePanel}
onSelectMyVoice={p.onSelectMyVoice}
onSelectVoiceAsset={p.onSelectVoiceAsset}
onToggleVoicePlay={p.onToggleVoicePlay}
onPickLocalAudio={p.onPickLocalAudio}
onPickAssetAudio={p.onPickAssetAudio}
onVoiceSelected={p.onVoiceSelected}
onRemoveRefAudio={p.onRemoveRefAudio}
onToggleVoiceTips={p.onToggleVoiceTips}
/>
<RecognitionBlock imageAnalysis={p.imageAnalysis} isAnalyzing={p.isAnalyzing} />
</div>
<button className="vv-btn vv-btn-primary" disabled={!p.canAnalyze} onClick={p.onAnalyze}>
{p.analyzeBtnText}
</button>
</div>
</div>
)
}
export default Step1UploadPanel
@@ -0,0 +1,237 @@
import React from "react"
import { EditOutlined } from "@ant-design/icons"
import { Select } from "antd"
import { FUSION_LEVELS } from "@/api/viral-video/types"
import type { FusionLevel } from "@/api/viral-video/types"
import { VVSpinner } from "./VVSpinner"
import { ErrorRetryBar } from "./ErrorRetryBar"
import { StoryboardCard } from "./StoryboardCard"
import type { SbDoc, StoryboardEditor } from "../hooks/useStoryboardEditor"
import type { UploadedImage, UIStep, ViralVideoJob } from "../types"
import {
LANGUAGES,
TARGET_CUSTOMERS,
PERSONAS,
STRUCTURES,
PURPOSES,
DURATIONS,
} from "../ViralVideoPage.constants"
import { getJobPhaseMessage } from "@/api/viral-video/types"
export interface Step2CopyPanelProps {
// 启用状态
step2Enabled: boolean
uiStep: UIStep
// 文案参数
fusionLevel: FusionLevel
userCopy: string
industry: string
targetCustomer: string
persona: string
language: string
viralStructure: string
marketingPurpose: string
duration: number
// 回调
onFusionLevelChange: (v: FusionLevel) => void
onUserCopyChange: (v: string) => void
onParamChange: (patch: {
industry?: string
targetCustomer?: string
persona?: string
language?: string
viralStructure?: string
marketingPurpose?: string
duration?: number
}) => void
onGenerateCopy: () => void
// 分镜/流式预览
storyboard: SbDoc | null
scriptStreamText: string
scriptStreamMessage?: string
editor: StoryboardEditor
images: UploadedImage[]
locked: boolean
job: ViralVideoJob | null
copyError?: string
}
export const Step2CopyPanel: React.FC<Step2CopyPanelProps> = (p) => {
const isGenerating = p.uiStep === "step2_generating"
const showGenerateBtn =
p.uiStep !== "step2_copy_ready" && p.uiStep !== "step3_generating" && p.uiStep !== "step3_done"
return (
<div className="vv-section">
<div className="vv-step-subhead">
<span className={`vv-step-badge ${p.step2Enabled ? "" : "vv-step-badge-dim"}`}>2</span>
<span className="vv-step-subtitle">STEP 2 生成视频文案</span>
{!p.step2Enabled && <span className="vv-step-lock">请先完成 STEP 1</span>}
</div>
<div className="vv-section-body vv-section-body-scroll">
<div className="vv-scroll-inner">
{/* 文案融合方式 */}
<div className="vv-section-subtitle">文案融合方式</div>
<div className="vv-fusion-grid">
{FUSION_LEVELS.map((f) => (
<button
key={f.value}
className={`vv-fusion-btn ${p.fusionLevel === f.value ? "active" : ""}`}
onClick={() => p.onFusionLevelChange(f.value)}
>
<strong>{f.label}</strong>
{f.desc}
</button>
))}
</div>
{(p.fusionLevel === "ai_polish" || p.fusionLevel === "user_primary") && (
<div className="vv-form-row">
<label className="vv-label">
{p.fusionLevel === "ai_polish" ? "写下你的文案草稿" : "写下你的文案/草稿"}
</label>
<textarea
key={p.fusionLevel}
className="vv-textarea"
placeholder={
p.fusionLevel === "ai_polish"
? "请输入你的文案草稿,AI将在此基础上润色优化..."
: "请输入你想要的原始文案,AI将严格保留你的表达..."
}
value={p.userCopy}
onChange={(e) => p.onUserCopyChange(e.target.value)}
/>
</div>
)}
{/* 参数(2列网格) */}
<div className="vv-form-grid">
<div className="vv-form-row">
<label className="vv-label">
行业与产品 <span className="vv-label-optional">(选填)</span>
</label>
<input
className="vv-input"
placeholder="如:家具店、宠物店、装修公司"
value={p.industry}
onChange={(e) => p.onParamChange({ industry: e.target.value })}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">目标客户</label>
<Select
className="vv-select"
style={{ width: "100%" }}
placeholder="请选择目标客户"
value={p.targetCustomer || undefined}
onChange={(v) => p.onParamChange({ targetCustomer: v })}
options={TARGET_CUSTOMERS.map((i) => ({ value: i, label: i }))}
allowClear
/>
</div>
<div className="vv-form-row">
<label className="vv-label">台词语言</label>
<Select
className="vv-select"
style={{ width: "100%" }}
value={p.language}
onChange={(v) => p.onParamChange({ language: v })}
options={LANGUAGES.map((i) => ({ value: i, label: i }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">我的人设</label>
<Select
className="vv-select"
style={{ width: "100%" }}
placeholder="请选择人设类型"
optionFilterProp="children"
value={p.persona || undefined}
onChange={(v) => p.onParamChange({ persona: v })}
options={PERSONAS.map((i) => ({ value: i, label: i }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">爆款结构</label>
<Select
className="vv-select"
style={{ width: "100%" }}
value={p.viralStructure}
onChange={(v) => p.onParamChange({ viralStructure: v })}
options={STRUCTURES.map((i) => ({ value: i, label: i }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">营销目的</label>
<Select
className="vv-select"
style={{ width: "100%" }}
placeholder="请选择营销目的"
value={p.marketingPurpose || undefined}
onChange={(v) => p.onParamChange({ marketingPurpose: v })}
options={PURPOSES.map((i) => ({ value: i, label: i }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">文案视频时长</label>
<Select
className="vv-select"
style={{ width: "100%" }}
value={p.duration}
onChange={(v) => p.onParamChange({ duration: v })}
options={DURATIONS.map((n) => ({ value: n, label: `${n}秒` }))}
/>
</div>
</div>
{/* 短剧模式提示 */}
{p.marketingPurpose && p.marketingPurpose.includes("短剧") && (
<div className="vv-shortdrama-hint">
💡 短剧模式已选中,对手戏角色将自动分配配角音色
</div>
)}
{/* 文案审核错误(持久显示) */}
{p.copyError && <ErrorRetryBar label="文案审核未通过" message={p.copyError} />}
{/* 分镜结果区 / 流式预览 */}
<StoryboardCard
uiStep={p.uiStep}
scriptStreamText={p.scriptStreamText}
scriptStreamMessage={p.scriptStreamMessage}
jobPhaseMessage={getJobPhaseMessage(p.job)}
storyboard={p.storyboard}
editor={p.editor}
images={p.images}
locked={p.locked}
onRegenerateCopy={p.onGenerateCopy}
/>
</div>
{showGenerateBtn && (
<button
className={`vv-btn vv-btn-primary ${!p.step2Enabled ? "vv-btn-disabled-gray" : ""}`}
onClick={p.onGenerateCopy}
disabled={isGenerating || !p.step2Enabled}
title={!p.step2Enabled ? "请先完成图片分析" : ""}
>
{isGenerating ? (
<>
<VVSpinner /> AI 生成文案中…
</>
) : !p.step2Enabled ? (
<>
<EditOutlined /> 生成文案(请先完成图片分析)
</>
) : (
<>
<EditOutlined /> 生成文案
</>
)}
</button>
)}
</div>
</div>
)
}
export default Step2CopyPanel
@@ -0,0 +1,216 @@
import React from "react"
import {
VideoCameraOutlined,
SoundOutlined,
ReloadOutlined,
HistoryOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import { Select } from "antd"
import { VVSpinner } from "./VVSpinner"
import { ErrorRetryBar } from "./ErrorRetryBar"
import type { UIStep, ViralVideoJob, ViralVideoModel } from "../types"
import { getJobPhaseMessage } from "@/api/viral-video/types"
import { RATIOS, QUALITY_OPTIONS, UPSCALE_OPTIONS } from "../ViralVideoPage.constants"
export interface Step3VideoPanelProps {
step3Enabled: boolean
uiStep: UIStep
videoRatio: string
videoModel: string
quality: string
upscale: string
duration: number
estimatedCredits: number | null
estimatedCreditsError: boolean
isGenerating: boolean
outputUrl: string | null
videoError?: string
creditsReady: boolean
job: ViralVideoJob | null
videoModels: ViralVideoModel[]
locked: boolean
// callbacks
onRatioChange: (v: string) => void
onModelChange: (v: string) => void
onQualityChange: (v: string) => void
onUpscaleChange: (v: string) => void
onGenerate: () => void
onReset: () => void
onRetry: () => void
}
export const Step3VideoPanel: React.FC<Step3VideoPanelProps> = (p) => {
const isDone = p.uiStep === "step3_done"
const isGenerating = p.uiStep === "step3_generating"
const isFailed = p.uiStep === "failed"
const curM = p.videoModels.find((m) => m && m.key === p.videoModel)
const supportedRes = Array.isArray(curM?.supported_resolutions)
? curM!.supported_resolutions
: QUALITY_OPTIONS.map((o) => o.v)
const qualityPool =
supportedRes && supportedRes.length > 0 ? supportedRes : QUALITY_OPTIONS.map((o) => o.v)
const qualityOptions = QUALITY_OPTIONS.filter((o) => qualityPool.includes(o.v))
return (
<>
<div className="vv-section">
<div className="vv-step-subhead vv-step3-head">
<div className="vv-step3-head-left">
<span className={`vv-step-badge ${p.step3Enabled ? "" : "vv-step-badge-dim"}`}>3</span>
<span className="vv-step-subtitle">STEP 3 生成视频</span>
</div>
{p.step3Enabled && (
<button
className="vv-btn vv-btn-ghost vv-btn-xs vv-history-btn"
onClick={() => {
// 历史作品功能待开发,此处保留原行为(message 由父组件触发)
}}
title="历史作品"
>
<HistoryOutlined /> 历史作品
</button>
)}
{!p.step3Enabled && <span className="vv-step-lock">请先完成 STEP 2</span>}
</div>
<div className="vv-step3-divider" />
<div className="vv-section-body vv-section-body-scroll vv-step3-config-body">
<div className="vv-scroll-inner">
<div className="vv-step3-grid">
<div className="vv-form-row">
<label className="vv-label">视频比例</label>
<Select
className="vv-select vv-select-step3"
style={{ width: "100%" }}
value={p.videoRatio}
onChange={p.onRatioChange}
disabled={p.locked}
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">视频模型</label>
<Select
className="vv-select vv-select-step3"
style={{ width: "100%" }}
value={p.videoModel}
disabled={p.locked}
onChange={(v) => p.onModelChange(v)}
options={p.videoModels
.filter((m) => m && typeof m === "object" && m.key)
.map((m) => ({ value: m.key, label: m.display_name || m.key }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">清晰度</label>
<Select
className="vv-select vv-select-step3"
style={{ width: "100%" }}
value={p.quality}
onChange={p.onQualityChange}
disabled={p.locked}
options={qualityOptions.map((o) => ({ value: o.v, label: o.label }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">成片超分</label>
<Select
className="vv-select vv-select-step3"
style={{ width: "100%" }}
value={p.upscale}
onChange={p.onUpscaleChange}
disabled={p.locked}
options={UPSCALE_OPTIONS.map((m) => ({ value: m.v, label: m.label }))}
/>
</div>
</div>
</div>
</div>
</div>
{p.creditsReady && (
<div className="vv-credits">
<span>
<SoundOutlined style={{ marginRight: 4 }} />
预计消耗
</span>
<strong>
{p.estimatedCreditsError
? "-- 积分"
: p.estimatedCredits === null
? "… 积分"
: `${p.estimatedCredits.toFixed(2)} 积分`}
</strong>
</div>
)}
{/* 视频预览 */}
<div className="vv-section vv-section-preview">
<div className="vv-section-body vv-preview-body">
<div className="vv-preview">
{isDone && p.outputUrl ? (
<video src={p.outputUrl} controls />
) : isGenerating ? (
<div className="vv-preview-placeholder">
<div className="ph-icon">
<LoadingOutlined className="vv-loading-ic" />
</div>
<div className="ph-txt">
AI 正在生成视频中…
<br />
预计 1-3 分钟,请勿关闭页面
</div>
{getJobPhaseMessage(p.job) && (
<div className="vv-muted vv-mt-6 vv-fs-12">{getJobPhaseMessage(p.job)}</div>
)}
</div>
) : (
<div className="vv-preview-placeholder">
<div className="ph-icon">🎬</div>
<div className="ph-txt">完成前面步骤后视频将在此处预览</div>
</div>
)}
</div>
</div>
</div>
{/* 错误条 */}
{isFailed && p.videoError && (
<ErrorRetryBar label="视频生成失败" message={p.videoError} onRetry={p.onRetry} />
)}
{/* 主按钮 */}
{!isDone ? (
<button
className={`vv-btn vv-btn-primary ${!p.step3Enabled ? "vv-btn-disabled-gray" : ""}`}
onClick={p.onGenerate}
disabled={isGenerating || !p.step3Enabled}
title={!p.step3Enabled ? "请先在 STEP 2 生成文案" : ""}
>
{isGenerating ? (
<>
<VVSpinner /> AI 正在生成视频中…
</>
) : !p.step3Enabled ? (
<>
<VideoCameraOutlined /> 开始生成视频(请先生成文案)
</>
) : (
<>
<VideoCameraOutlined /> 开始生成视频
</>
)}
</button>
) : (
p.outputUrl && (
<button className="vv-btn vv-btn-ghost vv-btn-block" onClick={p.onReset}>
<ReloadOutlined /> 再生成一个
</button>
)
)}
</>
)
}
export default Step3VideoPanel
@@ -0,0 +1,327 @@
import React from "react"
import { Select } from "antd"
import { FileTextOutlined, SoundOutlined, ReloadOutlined } from "@ant-design/icons"
import { InlineText } from "./InlineEditTextarea"
import { StoryboardShotItem } from "./StoryboardShotItem"
import { VVSpinner } from "./VVSpinner"
import type { StoryboardEditor, SbDoc } from "../hooks/useStoryboardEditor"
import type { UploadedImage, UIStep } from "../types"
import { RATIOS } from "../ViralVideoPage.constants"
export interface StoryboardCardProps {
/** 当前 uiStep(仅用于按钮/锁定判定) */
uiStep: UIStep
/** 当前流式预览文本(step2_generating 时优先展示) */
scriptStreamText: string
scriptStreamMessage?: string
jobPhaseMessage?: string
/** 真实 storyboard(已补默认值的 SbDoc) */
storyboard: SbDoc | null
editor: StoryboardEditor
images: UploadedImage[]
/** 是否锁定编辑(视频生成中/完成时为 true) */
locked: boolean
onRegenerateCopy?: () => void
}
import { renderMarkdown } from "../utils/renderMarkdown"
/** 解析 voiceover_script 中的 [speaker:xxx] 标记,渲染为带颜色的 React 片段 */
function renderVoiceoverWithSpeakers(text: string) {
if (!text) return null
const parts = text.split(/\[speaker:([^\]]+)\]/)
if (parts.length === 1) return <span>{text}</span>
const elements: React.ReactNode[] = []
const MAIN_SPEAKERS = ["店主", "主播", "老板", "我", "主讲人"]
for (let i = 1; i < parts.length; i += 2) {
const speaker = parts[i]
const segText = parts[i + 1] || ""
const isMain = MAIN_SPEAKERS.includes(speaker)
elements.push(
<span key={i}>
<strong className={isMain ? "vv-sb-speaker-main" : "vv-sb-speaker-guest"}>{speaker}</strong>
<span>{segText}</span>
</span>,
)
}
return <>{elements}</>
}
export const StoryboardCard: React.FC<StoryboardCardProps> = ({
uiStep,
scriptStreamText,
scriptStreamMessage,
jobPhaseMessage,
storyboard,
editor,
images,
locked,
onRegenerateCopy,
}) => {
const {
editingField,
startEdit,
cancelEdit,
commitField,
updateOverview,
updateConstraint,
addConstraint,
removeConstraint,
updateSbField,
} = editor
/* ── 生成中:流式预览 or 骨架 ── */
if (uiStep === "step2_generating") {
const hasStream = scriptStreamText.trim().length > 0
const stageMsg = scriptStreamMessage || jobPhaseMessage || "🎬 AI 正在创作视频分镜脚本..."
if (!hasStream) {
return (
<div className="vv-copy-box vv-copy-loading">
<VVSpinner />
<span className="vv-copy-loading-text">{stageMsg}</span>
<span className="vv-copy-loading-hint">预计 10-30 秒,请稍候</span>
</div>
)
}
return (
<div className="vv-copy-box vv-storyboard vv-streaming-box">
<div className="vv-streaming-header">
<VVSpinner />
<span className="vv-streaming-title">{stageMsg}</span>
<span className="vv-streaming-count">{scriptStreamText.length} 字</span>
</div>
<div className="vv-sb-doc vv-streaming-doc">
<pre className="vv-streaming-pre">
{scriptStreamText
.replace(/<\/?[a-zA-Z_][\w:-]*(?:\s[^>]*)?>/g, "")
.replace(/\s{3,}/g, "\n\n")
.trim()}
<span className="vv-streaming-caret" aria-hidden="true" />
</pre>
</div>
<div className="vv-sb-actions vv-streaming-actions">
<span className="vv-streaming-hint">AI 实时输出中,完成后可编辑分镜与口播文案</span>
</div>
</div>
)
}
/* ── 没有 storyboard 时的空态 ── */
if (!storyboard) {
if (uiStep === "step2_copy_ready" || uiStep === "step3_generating" || uiStep === "step3_done") {
return (
<div className="vv-copy-box vv-copy-loading">
<span className="vv-muted">暂无分镜数据</span>
</div>
)
}
return null
}
const sb = storyboard
const textCls = "vv-sb-field-val"
const taCls = "vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
return (
<div className="vv-copy-box vv-storyboard">
<div className="vv-sb-doc">
{/* 文案预览 markdown(只读) */}
{sb.copy_display_markdown && (
<div className="vv-copy-preview">
<h4 className="vv-sb-h vv-copy-preview-h">
<FileTextOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
文案预览
</h4>
<div
className="vv-md-body"
dangerouslySetInnerHTML={{ __html: renderMarkdown(sb.copy_display_markdown) }}
/>
</div>
)}
{/* 视频总览 */}
<h4 className="vv-sb-h">视频总览</h4>
<p className="vv-sb-inline-row">
<strong className="vv-sb-field-k">整体主题:</strong>
<InlineText
value={sb.overview.theme}
editing={!locked && editingField === "overview.theme"}
onStartEdit={() => startEdit("overview.theme")}
onCommit={(v) => commitField("overview.theme", v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={`${taCls} vv-sb-inline-edit-ta-sm`}
disabled={locked}
rows={1}
/>
</p>
<p className="vv-sb-inline-row">
<strong className="vv-sb-field-k">总时长:</strong>
<InlineText
value={sb.overview.total_duration}
editing={!locked && editingField === "overview.total_duration"}
onStartEdit={() => startEdit("overview.total_duration")}
onCommit={(v) => commitField("overview.total_duration", v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={`${taCls} vv-sb-inline-edit-ta-sm`}
disabled={locked}
rows={1}
/>
</p>
<p className="vv-sb-inline-row">
<strong className="vv-sb-field-k">画幅:</strong>
<Select
className="vv-select vv-sb-inline-select"
style={{ width: 90 }}
value={sb.overview.aspect_ratio}
disabled={locked}
onChange={(v) => updateOverview({ aspect_ratio: v })}
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
variant="borderless"
/>
</p>
{/* 场景与光线 */}
<h4 className="vv-sb-h">场景与光线</h4>
<p className="vv-sb-para">
<InlineText
value={sb.scene_and_lighting}
editing={!locked && editingField === "scene_and_lighting"}
onStartEdit={() => startEdit("scene_and_lighting")}
onCommit={(v) => commitField("scene_and_lighting", v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
placeholder="描述整体场景氛围、光线方向与色温…"
disabled={locked}
multiline
rows={2}
/>
</p>
{/* 逐镜头 */}
<h4 className="vv-sb-h">逐秒镜头拆解</h4>
<div className="vv-sb-doc-shots">
{sb.shots.map((sh, idx) => (
<StoryboardShotItem
key={idx}
index={idx}
shot={sh}
editor={editor}
images={images}
locked={locked}
/>
))}
</div>
{/* 硬性约束 */}
<h4 className="vv-sb-h">硬性约束</h4>
<div className="vv-sb-tags-block">
<div className="vv-sb-taglist">
{sb.hard_constraints.map((c, i) => (
<span key={i} className="vv-sb-tag vv-sb-tag-hard">
<input
className="vv-sb-tag-input"
value={c}
disabled={locked}
onChange={(e) => updateConstraint("hard_constraints", i, e.target.value)}
/>
{!locked && (
<button
className="vv-sb-tag-del"
onClick={() => removeConstraint("hard_constraints", i)}
aria-label="删除"
>
×
</button>
)}
</span>
))}
{!locked && (
<button className="vv-sb-tag-add" onClick={() => addConstraint("hard_constraints")}>
+ 添加
</button>
)}
</div>
</div>
{/* 负面提示词 */}
<h4 className="vv-sb-h">负面提示词</h4>
<div className="vv-sb-tags-block">
<div className="vv-sb-taglist">
{sb.negative_prompts.map((c, i) => (
<span key={i} className="vv-sb-tag vv-sb-tag-neg">
<input
className="vv-sb-tag-input"
value={c}
disabled={locked}
onChange={(e) => updateConstraint("negative_prompts", i, e.target.value)}
/>
{!locked && (
<button
className="vv-sb-tag-del"
onClick={() => removeConstraint("negative_prompts", i)}
aria-label="删除"
>
×
</button>
)}
</span>
))}
{!locked && (
<button className="vv-sb-tag-add" onClick={() => addConstraint("negative_prompts")}>
+ 添加
</button>
)}
</div>
</div>
{/* 完整口播稿 */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">
<SoundOutlined style={{ color: "#7c3aed", marginRight: 4 }} />
口播稿:
</strong>
{editingField === "voiceover_script" ? (
<textarea
className={taCls}
value={sb.voiceover_script}
autoFocus
onChange={(e) => updateSbField("voiceover_script", e.target.value)}
onBlur={() => {
// 受控模式:blur 即退出编辑(内容已实时同步)
if (editingField === "voiceover_script") cancelEdit()
}}
onKeyDown={(e) => {
if (e.key === "Escape") cancelEdit()
}}
placeholder="AI 合成配音用的完整口播稿…"
rows={4}
/>
) : (
<span
className={textCls}
onClick={() => {
if (!locked) startEdit("voiceover_script")
}}
style={{ cursor: locked ? "not-allowed" : "text" }}
>
{renderVoiceoverWithSpeakers(sb.voiceover_script)}
</span>
)}
</p>
</div>
<div className="vv-sb-actions">
{uiStep === "step2_copy_ready" && onRegenerateCopy && (
<button className="vv-btn vv-btn-ghost" onClick={onRegenerateCopy}>
<ReloadOutlined /> 重新生成文案
</button>
)}
</div>
</div>
)
}
export default StoryboardCard
@@ -0,0 +1,182 @@
import React from "react"
import { InlineText } from "./InlineEditTextarea"
import type { StoryboardEditor, SbShot } from "../hooks/useStoryboardEditor"
import type { UploadedImage } from "../types"
export interface StoryboardShotItemProps {
index: number
shot: SbShot
editor: StoryboardEditor
images: UploadedImage[]
locked: boolean
}
const MAIN_SPEAKERS = ["店主", "主播", "老板", "我", "主讲人"]
const SHOT_FIELD_KEY = (idx: number, field: string) => `shot.${idx}.${field}`
/** 单个镜头行:时间/说话人/景别/场景/动作/BGM/转场/参考图 */
export const StoryboardShotItem: React.FC<StoryboardShotItemProps> = ({
index,
shot,
editor,
images,
locked,
}) => {
const { editingField, startEdit, cancelEdit, commitField, updateShot } = editor
const refImg =
typeof shot.reference_image_index === "number" ? images[shot.reference_image_index] : undefined
const textCls = "vv-sb-field-val"
const taCls = "vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
const isMainSpeaker = shot.speaker ? MAIN_SPEAKERS.includes(shot.speaker) : false
return (
<div className="vv-sb-doc-shot">
{/* 时间 */}
<InlineText
value={shot.time_range}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "time_range")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "time_range"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "time_range"), v)}
onCancel={cancelEdit}
textClass="vv-sb-time-doc"
textareaClass={`${taCls} vv-sb-time-ta`}
placeholder="0-3秒"
disabled={locked}
rows={1}
/>
{/* 说话人标签(有 speaker 时展示,纯展示不加编辑) */}
{shot.speaker && (
<span
className={`vv-sb-speaker-tag ${isMainSpeaker ? "vv-sb-speaker-tag-main" : "vv-sb-speaker-tag-guest"}`}
>
{shot.speaker}
</span>
)}
{/* 景别/角度与运镜 */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">景别/角度与运镜:</strong>
<InlineText
value={shot.shot_type_angle_movement}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "shot_type_angle_movement")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "shot_type_angle_movement"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "shot_type_angle_movement"), v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
disabled={locked}
multiline
rows={2}
/>
</p>
{/* 场景与对白 */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">场景与对白:</strong>
<InlineText
value={shot.scene_and_dialogue}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "scene_and_dialogue")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "scene_and_dialogue"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "scene_and_dialogue"), v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
disabled={locked}
multiline
rows={2}
/>
</p>
{/* 动作与真人细节 */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">动作与真人细节:</strong>
<InlineText
value={shot.action_details}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "action_details")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "action_details"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "action_details"), v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
disabled={locked}
multiline
rows={2}
/>
</p>
{/* 音效/BGM */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">音效/BGM:</strong>
<InlineText
value={shot.audio_bgm}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "audio_bgm")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "audio_bgm"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "audio_bgm"), v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
disabled={locked}
multiline
rows={1}
/>
</p>
{/* 转场 */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">转场:</strong>
<InlineText
value={shot.transition}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "transition")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "transition"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "transition"), v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
disabled={locked}
multiline
rows={1}
/>
</p>
{/* 参考图片 */}
<p className="vv-sb-field vv-sb-ref-row">
<strong className="vv-sb-field-k">参考图片:</strong>
{refImg?.url ? (
<span className="vv-sb-ref-badge">
<img src={refImg.url} alt={`ref-${index}`} className="vv-sb-ref-thumb" />
{!locked && (
<button
className="vv-sb-ref-x"
onClick={() => updateShot(index, { reference_image_index: undefined })}
aria-label="移除参考图"
>
×
</button>
)}
</span>
) : !locked && images.length > 0 ? (
<span className="vv-sb-ref-pick">
{images.map((im, i) => (
<button
key={im.localId}
className="vv-sb-ref-opt"
onClick={() => updateShot(index, { reference_image_index: i })}
title={`@图片${i + 1}`}
>
<img src={im.url} alt={`img${i}`} />
</button>
))}
</span>
) : (
<span className="vv-sb-muted">未指定</span>
)}
</p>
</div>
)
}
export default StoryboardShotItem
@@ -0,0 +1,26 @@
import React from "react"
export interface VVSpinnerProps {
size?: number
color?: string
style?: React.CSSProperties
className?: string
}
/**
* 统一 spinner 组件:复用 vv-spinner CSS 动画类,避免多处散落的 LoadingOutlined
*/
export const VVSpinner: React.FC<VVSpinnerProps> = ({ size, color, style, className }) => {
return (
<span
className={`vv-spinner ${className || ""}`}
style={{
...(size ? { width: size, height: size, borderWidth: Math.max(2, size / 8) } : {}),
...(color ? { borderTopColor: color } : {}),
...style,
}}
/>
)
}
export default VVSpinner
@@ -0,0 +1,216 @@
import React from "react"
import {
UserOutlined,
AudioMutedOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
FileTextOutlined,
FolderOpenOutlined,
UploadOutlined,
CaretDownOutlined,
CaretRightOutlined,
PlusOutlined,
} from "@ant-design/icons"
import { message } from "antd"
import type { RefAudio, VoicePanel } from "../types"
import type { VoiceClone } from "@/api/voice-clone"
import { getVoiceClonePreview } from "@/api/voice-clone"
import type { AssetItem } from "@/api/assets"
import { VOICE_TIPS } from "../ViralVideoPage.constants"
export interface VoicePickerSectionProps {
refAudio: RefAudio
voicePanel: VoicePanel
voiceTipsOpen: boolean
myVoices: VoiceClone[]
myVoicesLoading: boolean
voiceAssets: { id: string; name: string; url: string; desc?: string }[]
playingVoiceId: string | null
voiceInputRef: React.Ref<HTMLInputElement>
onOpenCloneModal: () => void
onToggleVoicePanel: (p: VoicePanel) => void
onSelectMyVoice: (v: VoiceClone) => void
onSelectVoiceAsset: (a: AssetItem) => void
onToggleVoicePlay: (v: { id: string; url: string }) => void
onPickLocalAudio: () => void
onPickAssetAudio: () => void
onVoiceSelected: (file: File) => void
onRemoveRefAudio: () => void
onToggleVoiceTips: () => void
}
export const VoicePickerSection: React.FC<VoicePickerSectionProps> = (p) => {
return (
<div className="vv-subblock">
<div className="vv-subblock-head">
<span className="vv-subblock-title">AI 配音</span>
<span className="vv-opt-tag">选填</span>
<span className="vv-subblock-hint">默认使用我的克隆音色,也可选择内置音色或上传配音</span>
</div>
{/* 我的音色(主路径) */}
<div className="vv-voice-section">
<div className="vv-voice-section-head">
<span className="vv-voice-section-title">
<UserOutlined /> 我的音色
</span>
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onOpenCloneModal}>
<PlusOutlined /> 录制新音色
</button>
</div>
{p.myVoicesLoading ? (
<div className="vv-muted vv-voice-empty">加载中…</div>
) : p.myVoices.length === 0 ? (
<div className="vv-voice-empty">
<AudioMutedOutlined className="vv-voice-empty-ic" />
<div className="vv-voice-empty-text">还没有克隆音色</div>
<button className="vv-btn vv-btn-primary vv-btn-sm" onClick={p.onOpenCloneModal}>
去录制一个
</button>
</div>
) : (
<div className="vv-my-voice-list">
{p.myVoices.map((v) => {
const selected = p.refAudio?.source === "my_voice" && p.refAudio.id === v.id
const playing = p.playingVoiceId === `my-${v.id}`
return (
<div
key={v.id}
className={`vv-my-voice-item ${selected ? "selected" : ""}`}
onClick={() => p.onSelectMyVoice(v)}
>
<div className="vv-voice-radio" />
<div className="vv-voice-info">
<div className="vv-voice-name">{v.name}</div>
<div className="vv-voice-desc">
AI 克隆声音
{v.gender
? ` · ${v.gender === "male" ? "男" : v.gender === "female" ? "女" : ""}`
: ""}
</div>
</div>
<button
className={`vv-voice-play ${playing ? "playing" : ""}`}
onClick={(e) => {
e.stopPropagation()
if (v.sample_url) {
p.onToggleVoicePlay({ id: `my-${v.id}`, url: v.sample_url })
} else {
message.info("正在获取试听音频…")
getVoiceClonePreview(v.id)
.then((r) => {
if (r.audio_url)
p.onToggleVoicePlay({ id: `my-${v.id}`, url: r.audio_url })
})
.catch(() => message.error("试听获取失败"))
}
}}
>
{playing ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
</div>
)
})}
</div>
)}
</div>
{/* 次要选项 */}
<div className="vv-voice-alt-row">
<button
className={`vv-voice-alt-btn ${p.refAudio?.source === "preset" ? "selected" : ""}`}
onClick={() => p.onToggleVoicePanel("preset")}
>
<FileTextOutlined /> 内置音色
</button>
<button
className={`vv-voice-alt-btn ${p.refAudio?.source === "upload" || p.refAudio?.source === "library" ? "selected" : ""}`}
onClick={() => p.onToggleVoicePanel("library")}
>
<FolderOpenOutlined /> 上传配音
</button>
</div>
<input
ref={p.voiceInputRef}
type="file"
accept="audio/*"
hidden
onChange={(e) => {
if (e.target.files?.[0]) p.onVoiceSelected(e.target.files[0])
e.target.value = ""
}}
/>
{p.refAudio && (
<div className="vv-selected-audio">
<span className="vv-mr-6">🔊</span>
<span className="vv-flex-1">
已选:
{p.refAudio.source === "my_voice"
? `我的音色 · ${p.refAudio.name}`
: p.refAudio.source === "preset"
? `内置 · ${p.refAudio.name}`
: p.refAudio.name}
</span>
<button className="vv-link-btn" onClick={p.onRemoveRefAudio}>
移除
</button>
</div>
)}
{p.voicePanel === "library" && (
<div className="vv-voice-panel">
<div className="vv-voice-panel-actions">
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onPickLocalAudio}>
<UploadOutlined /> 本地上传音频文件
</button>
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onPickAssetAudio}>
<FolderOpenOutlined /> 从素材库选择
</button>
</div>
{p.voiceAssets.length === 0 ? (
<div className="vv-muted vv-text-center vv-p-12">暂无已上传的配音文件</div>
) : (
<div className="vv-voice-list">
{p.voiceAssets.map((v) => (
<div
key={v.id}
className={`vv-voice-item ${p.refAudio?.id === v.id && (p.refAudio.source === "library" || p.refAudio.source === "upload") ? "selected" : ""}`}
onClick={() =>
p.onSelectVoiceAsset({ id: v.id, name: v.name, file_url: v.url } as AssetItem)
}
>
<div className="vv-voice-radio" />
<div className="vv-voice-info">
<div className="vv-voice-name">{v.name}</div>
<div className="vv-voice-desc">{v.desc || ""}</div>
</div>
{v.url && (
<button
className={`vv-voice-play ${p.playingVoiceId === v.id ? "playing" : ""}`}
onClick={(e) => {
e.stopPropagation()
p.onToggleVoicePlay(v)
}}
>
{p.playingVoiceId === v.id ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
)}
</div>
))}
</div>
)}
</div>
)}
<div className="vv-tips">
<button className="vv-tips-head" onClick={p.onToggleVoiceTips}>
{p.voiceTipsOpen ? <CaretDownOutlined /> : <CaretRightOutlined />}
支持格式与录音建议
</button>
{p.voiceTipsOpen && <div className="vv-tips-body">{VOICE_TIPS}</div>}
</div>
</div>
)
}
export default VoicePickerSection
@@ -0,0 +1,140 @@
import { useCallback, useState } from "react"
// 最小化的 Storyboard 类型,避免循环依赖(字段名与 api/viral-video/types 对齐)
export interface SbShot {
time_range: string
speaker?: string
shot_type_angle_movement: string
scene_and_dialogue: string
action_details: string
audio_bgm: string
transition: string
reference_image_index?: number
}
export interface SbOverview {
theme: string
total_duration: string
aspect_ratio: string
}
export interface SbDoc {
overview: SbOverview
scene_and_lighting: string
shots: SbShot[]
hard_constraints: string[]
negative_prompts: string[]
voiceover_script: string
copy_display_markdown: string
}
export type SbUpdater = (patch: (sb: SbDoc) => SbDoc) => void
export interface StoryboardEditor {
editingField: string | null
startEdit: (field: string) => void
cancelEdit: () => void
updateShot: (idx: number, patch: Partial<SbShot>) => void
updateOverview: (patch: Partial<SbOverview>) => void
updateSbField: <K extends keyof SbDoc>(key: K, value: SbDoc[K]) => void
updateConstraint: (list: "hard_constraints" | "negative_prompts", idx: number, v: string) => void
addConstraint: (list: "hard_constraints" | "negative_prompts") => void
removeConstraint: (list: "hard_constraints" | "negative_prompts", idx: number) => void
commitField: (field: string, value: string) => void
}
/**
* 分镜脚本编辑器 hook:管理 editingField 状态,以及 storyboard 字段级更新方法。
* 父组件传入 updateSb(fn) 即可,fn 接收当前 sb 返回新 sb(immer 风格)。
*/
export function useStoryboardEditor(updateSb: SbUpdater): StoryboardEditor {
const [editingField, setEditingField] = useState<string | null>(null)
const startEdit = useCallback((field: string) => {
setEditingField(field)
}, [])
const cancelEdit = useCallback(() => {
setEditingField(null)
}, [])
const updateShot = useCallback(
(idx: number, patch: Partial<SbShot>) => {
updateSb((sb) => ({
...sb,
shots: sb.shots.map((s, i) => (i === idx ? { ...s, ...patch } : s)),
}))
},
[updateSb],
)
const updateOverview = useCallback(
(patch: Partial<SbOverview>) => {
updateSb((sb) => ({ ...sb, overview: { ...sb.overview, ...patch } }))
},
[updateSb],
)
const updateSbField = useCallback(
<K extends keyof SbDoc>(key: K, value: SbDoc[K]) => {
updateSb((sb) => ({ ...sb, [key]: value }))
},
[updateSb],
)
const updateConstraint = useCallback(
(list: "hard_constraints" | "negative_prompts", idx: number, v: string) => {
updateSb((sb) => {
const arr = [...sb[list]]
arr[idx] = v
return { ...sb, [list]: arr }
})
},
[updateSb],
)
const addConstraint = useCallback(
(list: "hard_constraints" | "negative_prompts") => {
updateSb((sb) => ({ ...sb, [list]: [...sb[list], ""] }))
},
[updateSb],
)
const removeConstraint = useCallback(
(list: "hard_constraints" | "negative_prompts", idx: number) => {
updateSb((sb) => ({ ...sb, [list]: sb[list].filter((_, i) => i !== idx) }))
},
[updateSb],
)
const commitField = useCallback(
(field: string, value: string) => {
if (field.startsWith("shot.")) {
const parts = field.split(".")
const idx = Number(parts[1])
const shotField = parts.slice(2).join(".") as keyof SbShot
updateShot(idx, { [shotField]: value } as Partial<SbShot>)
} else if (field.startsWith("overview.")) {
const f = field.split(".")[1] as keyof SbOverview
updateOverview({ [f]: value } as Partial<SbOverview>)
} else if (field === "scene_and_lighting" || field === "voiceover_script") {
updateSbField(field, value)
}
setEditingField(null)
},
[updateShot, updateOverview, updateSbField],
)
return {
editingField,
startEdit,
cancelEdit,
updateShot,
updateOverview,
updateSbField,
updateConstraint,
addConstraint,
removeConstraint,
commitField,
}
}
export default useStoryboardEditor
@@ -0,0 +1,403 @@
import { useCallback, useEffect } from "react"
import axios from "axios"
import { message } from "antd"
import { isVideoStage, isCopyStage, getJobStage } from "@/api/viral-video/types"
import type { UIStep, TabTask } from "../types"
import {
retryViralVideo,
analyzeViralImages,
generateViralCopy,
confirmViralCopy,
} from "@/api/viral-video"
import type { CopyResult } from "@/api/viral-video/types"
import type { ViralVideoJob } from "@/api/viral-video/types"
import { applyPollUpdate } from "../utils/applyPollUpdate"
import {
useViralVideoMedia,
type UseViralVideoMediaOptions,
type MediaActions,
} from "./useViralVideoMedia"
import { resolveModelSwitch } from "../utils/resolveModelSwitch"
export interface ViralVideoActionsApi extends MediaActions {
resetTask: () => void
handleAnalyzeImages: () => void
handleGenerateCopy: () => void
handleGenerateVideo: () => void
handleRetry: () => void
onPollUpdate: (job: ViralVideoJob) => void
onScriptDelta: (ev: { data: { full_text?: string }; progress?: number; message?: string }) => void
handleModelChange: (
v: string,
videoModels: { key: string; supported_resolutions?: string[]; max_duration?: number }[],
) => void
setTask: (patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => void
setAssetPicker: React.Dispatch<
React.SetStateAction<{ open: boolean; kind: "image" | "video" | "voice"; multiple: boolean }>
>
setCloneModalOpen: React.Dispatch<React.SetStateAction<boolean>>
setPresetVoicePickerOpen: React.Dispatch<React.SetStateAction<boolean>>
refreshVoiceAssets: () => void
reloadMyVoices: () => void
}
export interface UseViralVideoHandlersOptions extends Omit<UseViralVideoMediaOptions, "setTask"> {
activeId: string
setTasks: React.Dispatch<React.SetStateAction<TabTask[]>>
videoModels: {
key: string
supported_resolutions?: string[]
max_duration?: number
display_name?: string
}[]
}
function emptyTask(id: string, title: string): TabTask {
return {
id,
title,
images: [],
refVideo: null,
voicePanel: null,
refAudio: null,
fusionLevel: "ai_full",
styleStrength: "medium",
userCopy: "",
industry: "",
targetCustomer: "",
language: "中文(普通话)",
viralStructure: "反差破局+亮明观点+还原现状",
marketingPurpose: "",
duration: 20,
persona: "",
videoRatio: "9:16",
videoModel: "seedance-2.5",
quality: "480p",
upscale: "default",
estimatedCredits: null,
estimatedCreditsError: false,
uiStep: "step1_upload",
imageAnalysis: null,
storyboard: null,
voiceTipsOpen: false,
job: null,
jobId: null,
playingVoiceId: null,
scriptStreamText: "",
scriptStreamProgress: 0,
scriptStreamMessage: "",
}
}
const CLEAR_STREAM = {
scriptStreamText: "",
scriptStreamProgress: 0,
scriptStreamMessage: "",
} as const
/**
* 业务 action hook:聚合素材操作 + 三步骤 submit/retry/polling/model switch 等业务动作。
*/
export function useViralVideoHandlers(opts: UseViralVideoHandlersOptions): ViralVideoActionsApi {
const {
task,
activeId,
setTasks,
setAssetPicker,
setCloneModalOpen,
refreshVoiceAssets,
reloadMyVoices,
setPresetVoicePickerOpen,
} = opts
const setTask = useCallback(
(patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => {
setTasks((ts) =>
ts.map((t) =>
t.id === activeId ? (typeof patch === "function" ? patch(t) : { ...t, ...patch }) : t,
),
)
},
[activeId, setTasks],
)
const media = useViralVideoMedia({
task,
setTask,
setAssetPicker,
setCloneModalOpen,
setPresetVoicePickerOpen,
refreshVoiceAssets,
reloadMyVoices,
})
// 卸载时停掉正在播放的音频
useEffect(() => () => media.audioRef.current?.pause(), [media.audioRef])
const resetTask = () => {
media.audioRef.current?.pause()
setTasks((ts) => ts.map((t) => (t.id === activeId ? emptyTask(t.id, t.title) : t)))
}
const handleAnalyzeImages = async () => {
const readyImgs = task.images.filter((i) => i.ossUrl && !i.uploading)
if (readyImgs.length === 0) {
message.warning("请至少上传 1 张产品图")
return
}
if (task.images.some((i) => i.uploading)) {
message.warning("图片上传中,请稍候")
return
}
if (task.refVideo?.uploading) {
message.warning("参考视频上传中,请稍候")
return
}
setTask({
uiStep: "step1_analyzing",
jobId: null,
job: null,
analysisError: undefined,
copyError: undefined,
videoError: undefined,
imageAnalysis: undefined,
storyboard: undefined,
...CLEAR_STREAM,
estimatedCredits: null,
estimatedCreditsError: false,
})
try {
const job = await analyzeViralImages({
images: readyImgs.map((i) => i.ossUrl as string),
reference_video_url: task.refVideo?.ossUrl || undefined,
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
})
setTask({ job, jobId: job.id })
message.success("已提交图片分析…")
} catch (err: unknown) {
message.error(err instanceof Error ? err.message : "分析失败")
setTask({
uiStep: "failed",
analysisError: err instanceof Error ? err.message : "分析失败",
copyError: undefined,
videoError: undefined,
})
}
}
const handleGenerateCopy = async () => {
if (!task.jobId) {
message.warning("请先完成图片分析")
return
}
setTask({
uiStep: "step2_generating",
copyError: undefined,
videoError: undefined,
analysisError: undefined,
...CLEAR_STREAM,
})
try {
const job = await generateViralCopy(task.jobId, {
industry: task.industry || undefined,
target_customer: task.targetCustomer || undefined,
viral_structure: task.viralStructure || undefined,
marketing_purpose: task.marketingPurpose || undefined,
duration: task.duration,
persona_id: task.persona || undefined,
user_copy_text:
(task.fusionLevel === "user_primary" || task.fusionLevel === "ai_polish") && task.userCopy
? task.userCopy
: undefined,
fusion_level: task.fusionLevel,
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
voice_id:
task.refAudio?.source === "preset"
? task.refAudio.id
: task.refAudio?.source === "my_voice"
? task.refAudio.ttsVoiceId || task.refAudio.id
: undefined,
voice_source: task.refAudio?.source || undefined,
video_ratio: task.videoRatio,
video_model: task.videoModel,
})
setTask({ job })
} catch (err: unknown) {
message.error(err instanceof Error ? err.message : "文案生成失败")
setTask({
uiStep: "failed",
copyError: err instanceof Error ? err.message : "文案生成失败",
videoError: undefined,
analysisError: undefined,
})
}
}
const handleGenerateVideo = async () => {
setTask({
uiStep: "step3_generating",
videoError: undefined,
copyError: undefined,
analysisError: undefined,
...CLEAR_STREAM,
})
try {
let job
if (task.jobId && task.storyboard && task.storyboard.voiceover_script.trim()) {
const edited = task.storyboard.voiceover_script.trim()
const originalCopy =
(task.job?.copy_result as CopyResult | null | undefined)?.voiceover_script ||
task.job?.copy_result?.final_copy ||
""
job = await confirmViralCopy(task.jobId, {
edited_copy: edited && edited !== originalCopy.trim() ? edited : undefined,
video_model: task.videoModel,
video_resolution: task.quality,
video_ratio: task.videoRatio,
duration: task.duration,
voice_id:
task.refAudio?.source === "preset"
? task.refAudio.id
: task.refAudio?.source === "my_voice"
? task.refAudio.ttsVoiceId || task.refAudio.id
: undefined,
voice_source: task.refAudio?.source || undefined,
language: task.language || undefined,
})
} else {
message.warning("请先生成文案后再生成视频")
setTask({
uiStep: task.storyboard
? "step2_copy_ready"
: task.imageAnalysis
? "step1_done"
: "step1_upload",
})
return
}
setTask({ job, jobId: job.id })
message.success("视频已提交生成,预计 1-3 分钟…")
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.status === 402) {
const detail =
(err.response.data as { detail?: string; message?: string; msg?: string } | undefined)
?.detail ||
(err.response.data as { detail?: string; message?: string; msg?: string } | undefined)
?.message ||
"积分不足,请充值"
message.error(detail)
setTask({
uiStep: "step2_copy_ready",
videoError: detail,
copyError: undefined,
analysisError: undefined,
...CLEAR_STREAM,
})
return
}
const msg = err instanceof Error ? err.message : "提交失败"
message.error(msg)
setTask({ uiStep: "failed", videoError: msg, copyError: undefined, analysisError: undefined })
}
}
const handleRetry = async () => {
if (task.jobId) {
try {
const j = await retryViralVideo(task.jobId)
const retryStage = getJobStage(j)
let retryStep: UIStep = "step1_analyzing"
if (isVideoStage(retryStage)) retryStep = "step3_generating"
else if (isCopyStage(retryStage)) retryStep = "step2_generating"
else if (task.storyboard) retryStep = "step2_copy_ready"
else if (task.imageAnalysis) retryStep = "step1_done"
setTask({
job: j,
jobId: j.id,
uiStep: retryStep,
videoError: undefined,
copyError: undefined,
analysisError: undefined,
imageAnalysis: undefined,
storyboard: undefined,
...CLEAR_STREAM,
estimatedCredits: null,
estimatedCreditsError: false,
})
message.success("已重试")
} catch (err: unknown) {
message.error(err instanceof Error ? err.message : "重试失败")
}
} else {
setTask({
uiStep: task.storyboard ? "step2_copy_ready" : "step1_done",
jobId: null,
job: null,
videoError: undefined,
copyError: undefined,
analysisError: undefined,
imageAnalysis: undefined,
storyboard: undefined,
...CLEAR_STREAM,
estimatedCredits: null,
estimatedCreditsError: false,
})
}
}
const onPollUpdate = useCallback(
(job: ViralVideoJob) => {
setTask((t) => applyPollUpdate(t, job))
},
[setTask],
)
const onScriptDelta = useCallback(
(ev: { data: { full_text?: string }; progress?: number; message?: string }) => {
setTask((t) => ({
...t,
scriptStreamText: ev.data.full_text || t.scriptStreamText,
scriptStreamProgress:
typeof ev.progress === "number" ? ev.progress : t.scriptStreamProgress,
scriptStreamMessage: ev.message || t.scriptStreamMessage,
}))
},
[setTask],
)
const handleModelChange = (
v: string,
vms: { key: string; supported_resolutions?: string[]; max_duration?: number }[],
) => {
const m = vms.find((x) => x && x.key === v)
if (!m) {
setTask({ videoModel: v })
return
}
const { quality, duration } = resolveModelSwitch(
{ quality: task.quality, duration: task.duration },
m,
)
setTask({ videoModel: v, quality, duration })
}
return {
...media,
resetTask,
handleAnalyzeImages,
handleGenerateCopy,
handleGenerateVideo,
handleRetry,
onPollUpdate,
onScriptDelta,
handleModelChange,
setTask,
setAssetPicker,
setCloneModalOpen,
setPresetVoicePickerOpen,
refreshVoiceAssets,
reloadMyVoices,
}
}
export default useViralVideoHandlers
@@ -0,0 +1,349 @@
import { useCallback, useRef } from "react"
import { message } from "antd"
import { getAssetLibraries, uploadAssetDirect, type AssetItem } from "@/api/assets"
import type { UploadedImage, RefVideo, RefAudio, VoicePanel, TabTask } from "../types"
import type { VoiceClone } from "@/api/voice-clone"
export interface UseViralVideoMediaOptions {
task: TabTask
setTask: (patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => void
setAssetPicker: React.Dispatch<
React.SetStateAction<{ open: boolean; kind: "image" | "video" | "voice"; multiple: boolean }>
>
setCloneModalOpen: React.Dispatch<React.SetStateAction<boolean>>
setPresetVoicePickerOpen: React.Dispatch<React.SetStateAction<boolean>>
refreshVoiceAssets: () => void
reloadMyVoices: () => void
}
export interface MediaActions {
imgInputRef: React.MutableRefObject<HTMLInputElement | null>
videoInputRef: React.MutableRefObject<HTMLInputElement | null>
voiceInputRef: React.MutableRefObject<HTMLInputElement | null>
audioRef: React.MutableRefObject<HTMLAudioElement | null>
appendImagesFromFiles: (files: FileList | File[]) => void
appendImagesFromAssets: (assets: AssetItem[]) => void
clearImages: () => void
removeImg: (localId: string) => void
moveImg: (idx: number, dir: -1 | 1) => void
handleLocalRefVideo: (file: File) => void
removeRefVideo: () => void
handleVoiceFile: (file: File) => void
onCloneSuccess: (v: VoiceClone | { id?: string; name?: string; voice_id?: string | null }) => void
pickVoiceAsset: (a: AssetItem) => void
selectMyVoice: (v: VoiceClone) => void
toggleVoicePanel: (p: VoicePanel) => void
toggleVoice: (v: { id: string; url: string }) => void
openAssetPicker: (kind: "image" | "video" | "voice", multiple: boolean) => void
}
/**
* 素材上传/音色选择/图片列表/参考视频等操作:独立 hook,保持每个文件 ≤400 行。
*/
export function useViralVideoMedia(opts: UseViralVideoMediaOptions): MediaActions {
const {
task,
setTask,
setAssetPicker,
setCloneModalOpen,
refreshVoiceAssets,
setPresetVoicePickerOpen,
} = opts
const imgInputRef = useRef<HTMLInputElement>(null)
const videoInputRef = useRef<HTMLInputElement>(null)
const voiceInputRef = useRef<HTMLInputElement>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const uploadFile = useCallback(
async (file: File, kind: "image" | "video" | "voice", onProgress?: (pct: number) => void) => {
const libs = await getAssetLibraries()
const lib = (libs as { kind: string; id: string }[]).find((l) => l.kind === kind) || libs[0]
if (!lib) throw new Error("未找到素材库")
const res = await uploadAssetDirect({
file,
library_id: lib.id,
onProgress: (p) => onProgress?.(Math.round(p)),
})
return res.url
},
[],
)
const appendImagesFromFiles = useCallback(
async (files: FileList | File[]) => {
const remaining = 9 - task.images.length
if (remaining <= 0) {
message.warning("最多上传 9 张产品图")
return
}
const arr = Array.from(files)
.filter((f) => f.type.startsWith("image/"))
.slice(0, remaining)
if (arr.length === 0) return
const newImgs: UploadedImage[] = arr.map((f) => ({
localId: `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
url: URL.createObjectURL(f),
name: f.name,
size: f.size,
uploading: true,
pct: 0,
file: f,
}))
setTask((t) => ({
...t,
images: [...t.images, ...newImgs.map(({ file: _f, ...rest }) => rest)],
}))
for (const item of newImgs) {
try {
const url = await uploadFile(item.file!, "image", (p) => {
setTask((t) => ({
...t,
images: t.images.map((im) => (im.localId === item.localId ? { ...im, pct: p } : im)),
}))
})
setTask((t) => ({
...t,
images: t.images.map((im) =>
im.localId === item.localId
? {
...im,
uploading: false,
pct: 100,
ossUrl: url,
size: item.file!.size || im.size,
}
: im,
),
}))
} catch {
message.error(`${item.name} 上传失败`)
setTask((t) => ({ ...t, images: t.images.filter((im) => im.localId !== item.localId) }))
}
}
},
[task.images.length, setTask, uploadFile],
)
const appendImagesFromAssets = useCallback(
(assets: AssetItem[]) => {
const remaining = 9 - task.images.length
const picked = assets.slice(0, remaining)
if (picked.length === 0) return
const newImgs: UploadedImage[] = picked.map((a) => ({
localId: `lib_${a.id}_${Math.random().toString(36).slice(2, 6)}`,
url: a.thumbnail_url || a.file_url || "",
name: a.name || "未命名",
size: a.file_size || undefined,
uploading: false,
pct: 100,
ossUrl: a.file_url || undefined,
}))
setTask((t) => ({ ...t, images: [...t.images, ...newImgs] }))
message.success(`已添加 ${picked.length} 张图片`)
},
[task.images.length, setTask],
)
const clearImages = () => {
setTask((t) => {
t.images.forEach((im) => {
if (im.url?.startsWith("blob:")) URL.revokeObjectURL(im.url)
})
return {
...t,
images: [],
imageAnalysis: null,
uiStep: "step1_upload",
storyboard: null,
job: null,
jobId: null,
}
})
}
const removeImg = (localId: string) => {
setTask((t) => {
const im = t.images.find((i) => i.localId === localId)
if (im?.url?.startsWith("blob:")) URL.revokeObjectURL(im.url)
const newImgs = t.images.filter((i) => i.localId !== localId)
return {
...t,
images: newImgs,
imageAnalysis: newImgs.length === 0 ? null : t.imageAnalysis,
storyboard: newImgs.length === 0 ? null : t.storyboard,
}
})
}
const moveImg = (idx: number, dir: -1 | 1) => {
setTask((t) => {
const imgs = [...t.images]
const target = idx + dir
if (target < 0 || target >= imgs.length) return t
;[imgs[idx], imgs[target]] = [imgs[target], imgs[idx]]
return { ...t, images: imgs }
})
}
const handleLocalRefVideo = useCallback(
async (file: File) => {
if (file.size > 200 * 1024 * 1024) {
message.warning("视频不能超过 200MB")
return
}
const localUrl = URL.createObjectURL(file)
setTask({
refVideo: {
source: "local",
url: localUrl,
name: file.name,
uploading: true,
pct: 0,
file,
} as RefVideo,
})
try {
const url = await uploadFile(file, "video", (p) => {
setTask((t) => (t.refVideo ? { ...t, refVideo: { ...t.refVideo, pct: p } } : t))
})
setTask((t) =>
t.refVideo
? { ...t, refVideo: { ...t.refVideo, uploading: false, pct: 100, ossUrl: url } }
: t,
)
} catch {
message.error("参考视频上传失败")
if (localUrl.startsWith("blob:")) URL.revokeObjectURL(localUrl)
setTask({ refVideo: null })
}
},
[setTask, uploadFile],
)
const removeRefVideo = () => {
setTask((t) => {
if (t.refVideo?.url.startsWith("blob:")) URL.revokeObjectURL(t.refVideo.url)
return { ...t, refVideo: null }
})
}
const handleVoiceFile = useCallback(
async (file: File) => {
if (file.size > 10 * 1024 * 1024) {
message.warning("音频不能超过 10MB")
return
}
const localUrl = URL.createObjectURL(file)
try {
const url = await uploadFile(file, "voice")
setTask({
refAudio: { source: "upload", name: file.name, url, id: url } as RefAudio,
voicePanel: null,
})
message.success("音频已上传")
refreshVoiceAssets()
} catch {
message.error("音频上传失败")
URL.revokeObjectURL(localUrl)
}
},
[setTask, uploadFile, refreshVoiceAssets],
)
const onCloneSuccess = (
v: VoiceClone | { id?: string; name?: string; voice_id?: string | null },
) => {
setCloneModalOpen(false)
opts.reloadMyVoices()
const vid = "voice_id" in v ? v.voice_id : undefined
if (v?.id) {
setTask({
refAudio: {
source: "my_voice",
id: v.id,
name: v.name || "我录制的音色",
ttsVoiceId: vid || undefined,
} as RefAudio,
voicePanel: null,
})
refreshVoiceAssets()
message.success("音色克隆完成,已自动选中")
}
}
const pickVoiceAsset = (asset: AssetItem) => {
setTask({
refAudio: {
source: "library",
id: asset.id,
name: asset.name,
url: asset.file_url || "",
} as RefAudio,
voicePanel: null,
})
}
const selectMyVoice = (v: VoiceClone) => {
setTask({
refAudio: {
source: "my_voice",
id: v.id,
name: v.name,
ttsVoiceId: v.voice_id || undefined,
} as RefAudio,
voicePanel: null,
})
}
const toggleVoicePanel = (p: VoicePanel) => {
if (p === "preset") {
setPresetVoicePickerOpen(true)
return
}
setTask({ voicePanel: task.voicePanel === p ? null : p })
}
const openAssetPicker = (kind: "image" | "video" | "voice", multiple: boolean) => {
setAssetPicker({ open: true, kind, multiple })
}
const toggleVoice = (v: { id: string; url: string }) => {
if (!v.url) return
if (task.playingVoiceId === v.id) {
audioRef.current?.pause()
audioRef.current = null
setTask({ playingVoiceId: null })
return
}
audioRef.current?.pause()
const a = new Audio(v.url)
a.dataset.playingId = v.id
a.onended = () => setTask({ playingVoiceId: null })
a.play().catch(() => {})
audioRef.current = a
setTask({ playingVoiceId: v.id })
}
return {
imgInputRef,
videoInputRef,
voiceInputRef,
audioRef,
appendImagesFromFiles,
appendImagesFromAssets,
clearImages,
removeImg,
moveImg,
handleLocalRefVideo,
removeRefVideo,
handleVoiceFile,
onCloneSuccess,
pickVoiceAsset,
selectMyVoice,
toggleVoicePanel,
toggleVoice,
openAssetPicker,
}
}
export default useViralVideoMedia
@@ -16,8 +16,9 @@ export interface UseViralVideoPollingOptions {
/**
* 爆款视频任务 HTTP 轮询 hook。
* 负责持续拉取任务状态并回调给上层;上层负责根据状态/阶段切换 UI 文案。
* 任务进入终态(completed/failed/cancelled)后自动停止。
* - onUpdate 通过 ref 持有,避免 useCallback 依赖变化导致重启轮询。
* - 任务进入终态(completed/failed/cancelled)后自动停止。
* - 连续失败 20 次(约 2 分钟)后熔断。
*/
export function useViralVideoPolling(
jobId: string | null | undefined,
@@ -28,6 +29,9 @@ export function useViralVideoPolling(
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const stoppedRef = useRef(false)
const failCountRef = useRef(0)
// ref 持有最新 onUpdate,避免依赖变化导致 pollOnce 重建、轮询被重启
const onUpdateRef = useRef(onUpdate)
onUpdateRef.current = onUpdate
const stop = useCallback(() => {
stoppedRef.current = true
@@ -42,25 +46,22 @@ export function useViralVideoPolling(
try {
const job = await getViralVideoJob(id)
failCountRef.current = 0
onUpdate(job)
onUpdateRef.current(job)
if (TERMINAL.includes(job.status)) {
stop()
return
}
if (stoppedRef.current) return
// 视频渲染阶段(Seedance 多段视频生成较慢)拉长轮询间隔
const stage = getJobStage(job)
const inRender = stage === "rendering"
// 分析阶段走默认间隔即可
const isAnalyzing = isAnalysisStage(stage)
const nextDelay = inRender ? 3000 : isAnalyzing ? 2000 : intervalMs
timerRef.current = setTimeout(() => pollOnce(id), nextDelay)
} catch (_err) {
failCountRef.current += 1
if (stoppedRef.current) return
// P2-5: 连续失败 20 次(约 2 分钟)后熔断,避免后端挂掉时前端无限轮询
if (failCountRef.current >= 20) {
console.warn("[viral-video] 轮询连续失败 20 次,停止轮询,请刷新页面重试")
// 轮询连续失败 20 次,停止轮询
stop()
return
}
@@ -68,7 +69,7 @@ export function useViralVideoPolling(
timerRef.current = setTimeout(() => pollOnce(id), delay)
}
},
[intervalMs, onUpdate, stop],
[intervalMs, stop],
)
useEffect(() => {
+132
View File
@@ -0,0 +1,132 @@
import type {
FusionLevel,
StyleStrength,
ViralVideoJob,
ImageAnalysisResult,
CopyResult,
ShotScript,
ViralVideoModel,
} from "@/api/viral-video/types"
/* ─────────── UI 阶段(三步骤独立交互) ─────────── */
export type UIStep =
| "step1_upload" // 初始:上传素材
| "step1_analyzing" // 图片分析中
| "step1_done" // 图片分析完成
| "step2_generating" // 文案生成中
| "step2_copy_ready" // 文案已生成,等待确认
| "step3_generating" // 视频生成中
| "step3_done" // 视频完成
| "failed" // 失败
export const TERMINAL_UI_STEPS: UIStep[] = ["step3_done", "failed"]
export type UploadedImage = {
localId: string
url: string
name: string
size: number | undefined
uploading: boolean
pct: number
ossUrl?: string
file?: File
}
export type RefVideo = {
source: "local"
url: string
name: string
uploading: boolean
pct: number
ossUrl?: string
file?: File
} | null
export type RefAudio = {
source: "preset" | "library" | "upload" | "clone" | "my_voice"
id?: string
name: string
url?: string
/** 克隆音色的 CosyVoice voice_id(source=my_voice 时使用) */
ttsVoiceId?: string
} | null
export type VoicePanel = "my_voices" | "preset" | "upload" | "library" | "record" | null
// ── 分镜脚本数据模型(新后端 copy_result 结构) ──
export interface StoryboardShot {
time_range: string
speaker?: string
shot_type_angle_movement: string
scene_and_dialogue: string
action_details: string
audio_bgm: string
transition: string
reference_image_index?: number
}
export interface Storyboard {
overview: { theme: string; total_duration: string; aspect_ratio: string }
scene_and_lighting: string
shots: StoryboardShot[]
hard_constraints: string[]
negative_prompts: string[]
voiceover_script: string
/** v3: 用户端展示用 markdown 文案(由提示词控制排版) */
copy_display_markdown: string
}
export type TabTask = {
id: string
title: string
// 素材
images: UploadedImage[]
refVideo: RefVideo
voicePanel: VoicePanel
refAudio: RefAudio
// STEP2 参数
fusionLevel: FusionLevel
styleStrength: StyleStrength
userCopy: string
industry: string
targetCustomer: string
persona: string
language: string
viralStructure: string
marketingPurpose: string
duration: number
// STEP3 设置
videoRatio: string
videoModel: string
quality: string
upscale: string
// 积分预估
estimatedCredits: number | null
estimatedCreditsError: boolean
// UI 状态
uiStep: UIStep
imageAnalysis: ImageAnalysisResult | null
storyboard: Storyboard | null
voiceTipsOpen: boolean
copyError?: string
videoError?: string
analysisError?: string
// 运行态(后端 job)
job: ViralVideoJob | null
jobId: string | null
playingVoiceId: string | null
// 分镜脚本流式预览
scriptStreamText: string
scriptStreamProgress: number
scriptStreamMessage: string
}
// re-export api types for convenience
export type {
FusionLevel,
StyleStrength,
ViralVideoJob,
ImageAnalysisResult,
CopyResult,
ShotScript,
ViralVideoModel,
}
@@ -0,0 +1,99 @@
import { message } from "antd"
import {
isVideoStage,
isCopyStage,
isImageAnalysisStage,
getJobStage,
type ViralVideoJob,
} from "@/api/viral-video/types"
import type { UIStep, TabTask } from "../types"
import { TERMINAL_UI_STEPS } from "../types"
import { copyResultToStoryboard } from "./copyResultToStoryboard"
import { classifyJobError, cleanErrorMessage } from "./errorClassification"
/**
* 根据 job 状态计算下一个 TabTask 快照:单调性守卫 + 阶段推进 + 错误分类。
* 返回 null 表示丢弃此次更新(终态守卫命中,使用旧状态即可)。
*/
export function applyPollUpdate(t: TabTask, job: ViralVideoJob): TabTask {
if (
TERMINAL_UI_STEPS.includes(t.uiStep) &&
job.status !== "completed" &&
job.status !== "failed"
) {
return t
}
let nextStep: UIStep = t.uiStep
let copyError = t.copyError
let videoError = t.videoError
let analysisError = t.analysisError
const err = job.error_message || job.error_msg
const nextImageAnalysis = job.image_analysis || t.imageAnalysis
const nextCopy = job.copy_result
const nextStoryboard = nextCopy ? copyResultToStoryboard(nextCopy) || t.storyboard : t.storyboard
const stage = getJobStage(job)
switch (job.status) {
case "image_analyzed":
nextStep = "step1_done"
copyError = undefined
break
case "copy_generated":
nextStep = "step2_copy_ready"
copyError = undefined
break
case "running":
if (isVideoStage(stage)) nextStep = "step3_generating"
else if (isCopyStage(stage)) nextStep = "step2_generating"
else if (isImageAnalysisStage(stage)) nextStep = "step1_analyzing"
copyError = undefined
videoError = undefined
break
case "completed":
nextStep = "step3_done"
copyError = undefined
videoError = undefined
break
case "failed": {
if (stage === "review" && err && (err.includes("合规") || err.includes("审核未通过"))) {
nextStep = "step2_copy_ready"
copyError = err
videoError = undefined
analysisError = undefined
message.warning(err)
break
}
nextStep = "failed"
const cleaned = cleanErrorMessage(err || "")
const kind = classifyJobError(cleaned, stage)
if (kind === "video") {
videoError = cleaned || "视频生成失败"
copyError = undefined
analysisError = undefined
} else if (kind === "image") {
analysisError = cleaned || "图片分析失败"
copyError = undefined
videoError = undefined
} else {
copyError = cleaned || "文案生成失败"
videoError = undefined
analysisError = undefined
}
break
}
case "pending":
break
}
return {
...t,
job,
uiStep: nextStep,
imageAnalysis: nextImageAnalysis,
storyboard: nextStoryboard,
copyError,
videoError,
analysisError,
}
}
export default applyPollUpdate
@@ -0,0 +1,71 @@
import type { Storyboard } from "../types"
import type { CopyResult, ShotScript } from "@/api/viral-video/types"
export function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard | null {
if (!cr) return null
if (Array.isArray(cr.shots) && cr.overview) {
return {
overview: {
theme: cr.overview.theme || cr.title || "",
total_duration: (() => {
const d = cr.overview.total_duration
if (!d) return ""
return String(d).endsWith("s") ? String(d) : `${d}s`
})(),
aspect_ratio: cr.overview.aspect_ratio || "9:16",
},
scene_and_lighting: cr.scene_and_lighting || "",
shots: ((cr.shots ?? []) as ShotScript[]).map((s) => ({
time_range: s.time_range || "",
speaker: s.speaker || "",
shot_type_angle_movement: s.shot_type_angle_movement || "",
scene_and_dialogue: s.scene_and_dialogue || "",
action_details: s.action_details || "",
audio_bgm: s.audio_bgm || "",
transition: s.transition || "",
reference_image_index:
typeof s.reference_image_index === "number" ? s.reference_image_index : undefined,
})),
hard_constraints: Array.isArray(cr.hard_constraints) ? cr.hard_constraints : [],
negative_prompts: Array.isArray(cr.negative_prompts) ? cr.negative_prompts : [],
voiceover_script: cr.voiceover_script || cr.final_copy || cr.suggested_copy || "",
copy_display_markdown: cr.copy_display_markdown || "",
}
}
const finalCopy = cr.final_copy || cr.suggested_copy || ""
const title = cr.title || ""
return {
overview: { theme: title || "AI 分镜脚本", total_duration: "", aspect_ratio: "9:16" },
scene_and_lighting: "",
shots:
cr.scenes && cr.scenes.length
? cr.scenes.map((sc, i: number) => ({
time_range: sc.duration
? `${i * sc.duration}-${(i + 1) * sc.duration}s`
: `镜头${i + 1}`,
shot_type_angle_movement: sc.shot || "",
scene_and_dialogue: sc.narration || "",
action_details: "",
audio_bgm: "",
transition: "",
}))
: finalCopy
? [
{
time_range: "0-15s",
shot_type_angle_movement: "",
scene_and_dialogue: finalCopy,
action_details: "",
audio_bgm: "",
transition: "",
},
]
: [],
hard_constraints: [],
negative_prompts: [],
voiceover_script: finalCopy,
copy_display_markdown: cr.copy_display_markdown || "",
}
}
export default copyResultToStoryboard
@@ -0,0 +1,55 @@
import {
isCopyStage,
isImageAnalysisStage,
isVideoStage,
getJobStage,
} from "@/api/viral-video/types"
import type { ViralVideoJob, ViralVideoStage } from "@/api/viral-video/types"
/**
* 统一的视频/文案/图片阶段错误分类。
* 优先按 stage 字段判定;stage 丢失时基于错误消息关键词兜底。
*/
export type JobErrorKind = "image" | "copy" | "video"
const VIDEO_ERR_RE =
/wan 3\.0|wan-?3|seedance|视频生成|视频下载|视频渲染|tts|配音|render|musetalk|lip.?sync/i
const IMAGE_ERR_RE = /vlm|图片分析|vision|商品识别|ocr|image analysis|图片识别|图像分析/i
export function classifyJobError(err: string, stage?: string | null): JobErrorKind {
const msg = err || ""
if (stage) {
const s = stage as ViralVideoStage
if (isVideoStage(s)) return "video"
if (isImageAnalysisStage(s)) return "image"
if (isCopyStage(s)) return "copy"
}
if (VIDEO_ERR_RE.test(msg)) return "video"
if (IMAGE_ERR_RE.test(msg)) return "image"
return "copy"
}
/**
* 去掉后端错误消息中常见的"XX失败:"前缀,避免前端 UI 重复前缀。
*/
const ERROR_PREFIX_RE =
/^(视频生成失败|文案生成失败|图片分析失败|Wan 3\.0 视频生成失败|Seedance[0-9. ]*视频生成失败)[::]\s*/g
export function cleanErrorMessage(err: string): string {
if (!err) return ""
return err.replace(ERROR_PREFIX_RE, "").trim()
}
/**
* 基于 job 统一提取错误分类和清理后的消息;无错误返回 null。
*/
export function extractJobError(
job: ViralVideoJob | null | undefined,
): { kind: JobErrorKind; msg: string } | null {
if (!job) return null
const raw = job.error_message || job.error_msg
if (!raw) return null
const stage = getJobStage(job)
const kind = classifyJobError(raw, stage)
return { kind, msg: cleanErrorMessage(raw) }
}
@@ -0,0 +1,70 @@
/**
* 极简 markdown 渲染器:仅支持粗体、斜体、标题、列表、换行。
* 不依赖 marked 包(node_modules 中暂未安装),满足 viral-video 文案预览的
* 简单排版需求。
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
function inline(md: string): string {
let s = escapeHtml(md)
// bold **xxx**
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
// italic *xxx*
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
// inline code `xxx`
s = s.replace(/`([^`]+)`/g, "<code>$1</code>")
return s
}
export function renderMarkdown(md: string): string {
if (!md) return ""
const lines = md.split(/\r?\n/)
const out: string[] = []
let inList = false
for (const raw of lines) {
const line = raw.trimEnd()
if (!line.trim()) {
if (inList) {
out.push("</ul>")
inList = false
}
continue
}
// headings
const h = line.match(/^(#{1,4})\s+(.*)$/)
if (h) {
if (inList) {
out.push("</ul>")
inList = false
}
const lvl = h[1].length
out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`)
continue
}
// unordered list
const li = line.match(/^\s*[-*]\s+(.*)$/)
if (li) {
if (!inList) {
out.push("<ul>")
inList = true
}
out.push(`<li>${inline(li[1])}</li>`)
continue
}
if (inList) {
out.push("</ul>")
inList = false
}
out.push(`<p>${inline(line)}</p>`)
}
if (inList) out.push("</ul>")
return out.join("\n")
}
export default renderMarkdown
@@ -0,0 +1,30 @@
import { RESOLUTION_ORDER } from "../ViralVideoPage.constants"
/**
* resolveModelSwitch 所需的最小模型信息(结构性类型,兼容 ViralVideoModel)。
*/
export interface ModelSwitchInfo {
supported_resolutions?: string[]
max_duration?: number
}
/**
* 切换视频模型时,联动修正分辨率/时长:新模型不支持当前分辨率则回退到最低支持,
* 当前时长超过新模型 max_duration 则截断到 max_duration。
*/
export function resolveModelSwitch(
cur: { quality: string; duration: number },
m: ModelSwitchInfo,
): { quality: string; duration: number } {
const mRes = Array.isArray(m.supported_resolutions)
? m.supported_resolutions
: ["480p", "720p", "1080p"]
const sortedRes = mRes.slice().sort((a, b) => {
const ra = RESOLUTION_ORDER.indexOf(a)
const rb = RESOLUTION_ORDER.indexOf(b)
return (rb === -1 ? -1 : rb) - (ra === -1 ? -1 : ra)
})
const newRes = mRes.includes(cur.quality) ? cur.quality : sortedRes[0] || "480p"
const newDur = Math.min(cur.duration, typeof m.max_duration === "number" ? m.max_duration : 15)
return { quality: newRes, duration: newDur }
}
+1 -21
View File
@@ -1,8 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import {
generateViralVideo,
getViralVideoJob,
confirmViralVideoIntent,
retryViralVideo,
getViralVideoHistory,
getViralStyleTemplates,
@@ -76,10 +74,10 @@ describe("viral-video constants & stage helpers", () => {
})
it("isCopyStage", () => {
expect(isCopyStage("intent_parsing")).toBe(true)
expect(isCopyStage("script_generation")).toBe(true)
expect(isCopyStage("review")).toBe(true)
expect(isCopyStage("tts")).toBe(false)
expect(isCopyStage("image_analysis")).toBe(false)
})
it("isAnalysisStage is union", () => {
@@ -101,14 +99,6 @@ describe("viral-video API wrappers", () => {
mockPost.mockResolvedValue({ data: {} })
})
it("generateViralVideo", async () => {
mockPost.mockResolvedValue({ data: { id: "j1" } })
const r = generateViralVideo({ images: ["img1"] } as never)
vi.runAllTimersAsync()
expect(await r).toEqual({ id: "j1" })
expect(mockPost).toHaveBeenCalledWith("/viral-video/generate", { images: ["img1"] })
})
it("getViralVideoJob", async () => {
mockGet.mockResolvedValue({ data: { id: "j2" } })
const r = getViralVideoJob("j2")
@@ -117,16 +107,6 @@ describe("viral-video API wrappers", () => {
expect(mockGet).toHaveBeenCalledWith("/viral-video/j2")
})
it("confirmViralVideoIntent", async () => {
mockPost.mockResolvedValue({ data: { id: "j3" } })
const r = confirmViralVideoIntent("j3", { confirmed_copy: "hi" })
vi.runAllTimersAsync()
await r
expect(mockPost).toHaveBeenCalledWith("/viral-video/j3/confirm-intent", {
confirmed_copy: "hi",
})
})
it("retryViralVideo", async () => {
mockPost.mockResolvedValue({ data: { id: "j4" } })
await retryViralVideo("j4")
File diff suppressed because it is too large Load Diff
+3
View File
@@ -252,6 +252,9 @@ DOUBAO_TIMEOUT=${DOUBAO_TIMEOUT}
# 最大重试次数
DOUBAO_MAX_RETRIES=${DOUBAO_MAX_RETRIES}
# Ditto 情绪分析 LLM 调用超时(秒)
DITTO_EMOTION_TIMEOUT=${DITTO_EMOTION_TIMEOUT}
# 视觉模型(支持图片/视频理解的模型,model name 格式)
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
@@ -56,7 +56,6 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
current_stage=getattr(model, "current_stage", "") or "",
phase_message=getattr(model, "phase_message", "") or "",
heartbeat_at=getattr(model, "heartbeat_at", None),
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 "",
@@ -110,7 +109,6 @@ class SQLAlchemyViralVideoJobRepository:
current_stage=job.current_stage or "",
phase_message=job.phase_message or "",
heartbeat_at=job.heartbeat_at,
intent_result=job.intent_result,
image_analysis=job.image_analysis,
storyboard=job.storyboard,
generated_copy_text=job.generated_copy_text,
@@ -139,7 +137,6 @@ class SQLAlchemyViralVideoJobRepository:
model.current_stage = job.current_stage or ""
model.phase_message = job.phase_message or ""
model.heartbeat_at = job.heartbeat_at
model.intent_result = job.intent_result
model.image_analysis = job.image_analysis
model.storyboard = job.storyboard
model.generated_copy_text = job.generated_copy_text or ""
@@ -199,9 +196,7 @@ class SQLAlchemyViralVideoJobRepository:
self.session.query(ViralVideoJobModel)
.filter(
ViralVideoJobModel.user_id == user_id,
ViralVideoJobModel.status.in_(
["pending", "running", "wait_user_confirm", "image_analyzed", "copy_generated"]
),
ViralVideoJobModel.status.in_(["pending", "running", "image_analyzed", "copy_generated"]),
)
.count()
)
@@ -37,9 +37,10 @@ _SENT_SPLIT_RE = re.compile(r"(?<=[。!?;!?;])\s*")
_CLAUSE_SPLIT_RE = re.compile(r"(?<=[,,、::])")
# 整段无句末标点且超过该长度时才做逗号二次切分
_SINGLE_SENTENCE_THRESHOLD = 30
# 二次切分后过短片段(<5 字,如"家人们,")并入相邻句避免切太碎;
# 4 字以上语义完整的短句(如"心中有爱,")保留独立成段
_MIN_CLAUSE_LEN = 5
# 二次切分后过短片段(<10 字)向后并入相邻句,确保每段至少 10 字、语义相对完整
# (如"心中有爱,眼里有光,"合并为一段);避免长文案被切成十几个碎段导致
# LLM 处理的句子过多而超时,且过碎片段语义不完整、情绪分析意义不大
_MIN_CLAUSE_LEN = 10
# ── 默认 prompt 模板文件路径 ──────────────────────────────────────
_DEFAULT_PROMPT_PATH = Path(__file__).parent / "prompts" / "ditto_emotion.txt"
@@ -94,9 +95,10 @@ class EmotionTimelineEntry:
# ── 分句 ─────────────────────────────────────────────────────────
def _split_by_clauses(sentence: str) -> list[str]:
"""按逗号/分号等语义停顿二次切分;过短片段(<5 字)并入相邻句,避免切太碎.
"""按逗号/分号等语义停顿二次切分;过短片段(<10 字)向后并入相邻句,避免切太碎.
每句尽量控制在 10-50 字左右(50 字为软上限,无更细停顿点时保持语义完整不硬切)。
每段至少 10 字、尽量控制在 10-50 字左右(50 字为软上限,无更细停顿点时保持
语义完整不硬切)。例:"心中有爱,眼里有光,"合并为一段而非各自成段。
"""
raw = [c for c in _CLAUSE_SPLIT_RE.split(sentence) if c and c.strip()]
if len(raw) <= 1:
@@ -49,3 +49,38 @@ def get_expected_shot_count(dur: int) -> tuple[int, int]:
"""返回 (min_shots, max_shots),供代码校验使用。"""
spec = get_duration_spec(dur)
return spec["min_shots"], spec["max_shots"]
# ── Seedance 生成默认约束(worker/API 共用,避免硬编码漂移) ──────────────
DEFAULT_HARD_CONSTRAINTS: list[str] = [
"无字幕、无水印、无任何自动生成文字、无 logo",
"严格还原参考图片中的真实场景、门店环境、商品陈列、人物外貌服装特征,不得凭空生成与参考图无关的人物、场景或物品",
"同一人物全程保持一致的五官、发型、服装、身材,不得换脸或变形",
"口播语音必须在指定时长内自然念完,语速自然,口型与语音同步",
"画面流畅无闪烁、无多余肢体、无扭曲变形、无穿模",
"色彩自然、曝光正确、电影级质感、高清细节",
]
DEFAULT_NEGATIVE_PROMPTS: list[str] = [
"字幕",
"自动字幕",
"水印",
"logo",
"图标",
"错误文字",
"乱码文字",
"男女声错配",
"中途换声",
"五官崩坏",
"脸部变形",
"多余手指",
"肢体扭曲",
"闪烁",
"画面抖动",
"模糊",
"低分辨率",
]
# API _build_copy_result 兼容老数据的兜底(比 worker 的短,仅用于老字段拼装)
API_FALLBACK_HARD_CONSTRAINTS: list[str] = ["无字幕", "无水印", "人物一致性"]
API_FALLBACK_NEGATIVE_PROMPTS: list[str] = ["字幕", "水印", "错误文字", "五官崩坏"]
@@ -0,0 +1,115 @@
"""爆款视频参数指导(persona/viral_structure/marketing_purpose/target_customer/language)。
PR3 P2(h): 把原来 worker 里 130 行 if/elif 硬编码 mapping 迁到 DB 表
`viral_video_param_guides`,提供 30 秒 TTL 缓存读取。DB miss 时返回 None,
由调用方兜底到代码默认值(保证 worker 冷启动/DB 不可用时不阻断流程)。
"""
from __future__ import annotations
import threading
import time
from typing import Optional
import sqlalchemy as sa
from packages.adapters.sqlalchemy_impl import session as _session_mod
CACHE_TTL_SECONDS = 30.0
VALID_GUIDE_TYPES = frozenset({"persona", "structure", "marketing_purpose", "target_customer", "language"})
_lock = threading.Lock()
# key = f"{guide_type}:{guide_key}", value = (expire_monotonic_ts, text|None)
_cache: dict[str, tuple[float, Optional[str]]] = {}
_lazy_session = None
def _get_session():
"""优先复用 worker 的 SessionLocal;否则懒建(API 场景)。"""
global _lazy_session
if _session_mod.SessionLocal is not None:
return _session_mod.SessionLocal()
if _lazy_session is not None:
return _lazy_session()
try:
from packages.config import get_shared_settings
url = str(get_shared_settings().database_url)
except Exception: # noqa: BLE001
return None
if not url:
return None
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
if url.startswith("postgresql://") and "psycopg" not in url:
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
engine = sa.create_engine(url, pool_pre_ping=True, pool_size=2, max_overflow=2)
from sqlalchemy.orm import sessionmaker
_lazy_session = sessionmaker(bind=engine)
return _lazy_session()
def _cache_key(guide_type: str, guide_key: str) -> str:
return f"{guide_type}:{guide_key}"
def _load_from_db(guide_type: str, guide_key: str) -> Optional[str]:
session = None
try:
session = _get_session()
if session is None:
return None
sql = sa.text("""
SELECT guide_text
FROM viral_video_param_guides
WHERE guide_type = :gt AND guide_key = :gk
""")
row = session.execute(sql, {"gt": guide_type, "gk": guide_key}).first()
if row is None:
return None
return row[0] if row[0] is not None else None
except Exception: # noqa: BLE001 - 表不存在/DB 不可用静默返回 None
return None
finally:
if session is not None:
try:
session.close()
except Exception: # noqa: BLE001
pass
def get_param_guide(guide_type: str, guide_key: str, *, force_refresh: bool = False) -> Optional[str]:
"""读参数指导文本,30 秒 TTL;DB 无数据返回 None,调用方自行兜底。"""
if guide_type not in VALID_GUIDE_TYPES:
return None
gk = (guide_key or "").strip()
if not gk:
return None
key = _cache_key(guide_type, gk)
now = time.monotonic()
with _lock:
if not force_refresh:
cached = _cache.get(key)
if cached and now < cached[0]:
return cached[1]
text = _load_from_db(guide_type, gk)
with _lock:
_cache[key] = (now + CACHE_TTL_SECONDS, text)
return text
def invalidate() -> None:
"""清空缓存(测试用)。"""
with _lock:
_cache.clear()
__all__ = [
"CACHE_TTL_SECONDS",
"VALID_GUIDE_TYPES",
"get_param_guide",
"invalidate",
]
@@ -1,10 +1,12 @@
"""Prompt 模板加载器:从 viral_video_prompt_templates 读模板,30 秒 TTL 热加载。
DB 不可用或没有数据时自动回落到 prompts.DEFAULT_TEMPLATES,保证流程不阻断。
DB 不可用或没有数据时自动回落到 prompts.DEFAULT_TEMPLATES,保证流程不阻断;
首次回落会打 warning(PR3 P2(f)),避免静默掩盖配置问题,又不刷屏。
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
@@ -15,10 +17,28 @@ import sqlalchemy as sa
from packages.adapters.sqlalchemy_impl import session as _session_mod
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
logger = logging.getLogger(__name__)
CACHE_TTL_SECONDS = 30.0
_VALID_TYPES = {"image_analysis", "storyboard", "review"}
# PR3 P2(f): 回落 warning 去重 — key -> 上次打 warning 的 monotonic 时间戳,
# 同一 key 在 _WARN_COOLDOWN_SECONDS 内不再重复告警。
_WARN_COOLDOWN_SECONDS = 300.0 # 5 分钟
_warned: dict[str, float] = {}
_warn_lock = threading.Lock()
def _warn_once(key: str, template_type: str, e: BaseException | str | None = None) -> None:
now = time.monotonic()
with _warn_lock:
last = _warned.get(key, 0.0)
if now - last < _WARN_COOLDOWN_SECONDS:
return
_warned[key] = now
logger.warning("viral_video prompt template %s fallback to default: %s", template_type, e)
@dataclass
class PromptTemplate:
@@ -64,12 +84,15 @@ def _get_session():
from packages.config import get_shared_settings
url = str(get_shared_settings().database_url)
except Exception: # noqa: BLE001
except Exception as e: # noqa: BLE001
_warn_once("session_config", "db_config", e)
return None
if not url:
_warn_once("session_url", "db_url", "database_url empty")
return None
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
url = url.replace("postgresql://", "postgresql+psycopg://") if url.startswith("postgresql://") else url
if url.startswith("postgresql://") and "psycopg" not in url:
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
engine = sa.create_engine(url, pool_pre_ping=True, pool_size=2, max_overflow=2)
from sqlalchemy.orm import sessionmaker
@@ -104,14 +127,15 @@ def _load_from_db(prompt_type: str) -> Optional[PromptTemplate]:
example_output=row[5] or "",
is_active=bool(row[6]),
)
except Exception: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
except Exception as e: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
_warn_once(f"load_db:{prompt_type}", prompt_type, e)
return None
finally:
if session is not None:
try:
session.close()
except Exception: # noqa: BLE001
pass
except Exception as e: # noqa: BLE001
_warn_once(f"session_close:{prompt_type}", prompt_type, e)
def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[PromptTemplate]:
@@ -126,6 +150,8 @@ def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[P
return cached[1]
template = _load_from_db(prompt_type) or _fallback(prompt_type)
if template is None:
_warn_once(f"no_template:{prompt_type}", prompt_type, "no template in db or code defaults")
if template is not None:
with _lock:
_cache[prompt_type] = (now, template)
@@ -136,6 +162,8 @@ def invalidate() -> None:
"""清空缓存(测试用)。"""
with _lock:
_cache.clear()
with _warn_lock:
_warned.clear()
class _SafeDict(dict):
@@ -146,7 +174,8 @@ class _SafeDict(dict):
def _safe_format(text: str, kwargs: dict) -> str:
try:
return text.format_map(_SafeDict(kwargs))
except Exception: # noqa: BLE001
except Exception as e: # noqa: BLE001
_warn_once("safe_format", "format", e)
return text
@@ -37,26 +37,6 @@ class ImageAnalysis(BaseModel):
raw: str = ""
class CoreMessage(BaseModel):
text: str
must_keep: bool = False
confidence: float = 0.0
class PersonalBrand(BaseModel):
text: str
category: str = "brand"
class IntentResult(BaseModel):
intent_summary: str = ""
core_messages: list[CoreMessage] = Field(default_factory=list)
personal_brands: list[PersonalBrand] = Field(default_factory=list)
emotion_tone: str = ""
missing_info: list[str] = Field(default_factory=list)
raw: str = ""
class BodyPoint(BaseModel):
text: str
elaboration: str = ""
+1 -1
View File
@@ -223,7 +223,7 @@ class SharedSettings(BaseSettings):
validation_alias=AliasChoices("DITTO_EMOTION_TEMPERATURE", "ditto_emotion_temperature"),
)
ditto_emotion_timeout: int = Field(
default=10,
default=30,
validation_alias=AliasChoices("DITTO_EMOTION_TIMEOUT", "ditto_emotion_timeout"),
)
ditto_emotion_max_tokens: int = Field(
+86
View File
@@ -0,0 +1,86 @@
"""爆款视频领域层包(PR3 P2:从单文件转为包,保持原有对外符号不变)。"""
from packages.domain.viral_video.entities import (
STAGE_LABELS,
FusionLevel,
PromptType,
StyleStrength,
ViralVideoJob,
ViralVideoStage,
ViralVideoStatus,
)
from packages.domain.viral_video.errors import (
ViralVideoComplianceError,
ViralVideoConfigError,
ViralVideoCreditsError,
ViralVideoError,
ViralVideoLLMError,
ViralVideoResourceError,
ViralVideoTimeoutError,
ViralVideoUpstreamError,
)
from packages.domain.viral_video.stages import (
PROGRESS_COPY_DONE,
PROGRESS_COPY_START,
PROGRESS_COPY_STREAMING,
PROGRESS_DONE,
PROGRESS_IMAGE_DONE,
PROGRESS_REVIEW,
PROGRESS_START,
PROGRESS_TRUST,
PROGRESS_TTS,
PROGRESS_UPLOAD_DONE,
PROGRESS_VIDEO_DONE,
PROGRESS_VIDEO_START,
STAGE_COPY_GENERATION,
STAGE_IMAGE_ANALYSIS,
STAGE_PHASE_MESSAGES,
STAGE_REVIEW,
STAGE_TRUST_CHAIN,
STAGE_TTS,
STAGE_UPLOADING,
STAGE_VIDEO_ANALYSIS,
STAGE_VIDEO_GENERATION,
)
__all__ = [
# entities
"FusionLevel",
"PromptType",
"STAGE_LABELS",
"StyleStrength",
"ViralVideoJob",
"ViralVideoStage",
"ViralVideoStatus",
# errors
"ViralVideoError",
"ViralVideoConfigError",
"ViralVideoUpstreamError",
"ViralVideoComplianceError",
"ViralVideoResourceError",
"ViralVideoCreditsError",
"ViralVideoTimeoutError",
"ViralVideoLLMError",
# stages
"STAGE_IMAGE_ANALYSIS",
"STAGE_VIDEO_ANALYSIS",
"STAGE_COPY_GENERATION",
"STAGE_REVIEW",
"STAGE_TRUST_CHAIN",
"STAGE_TTS",
"STAGE_VIDEO_GENERATION",
"STAGE_UPLOADING",
"STAGE_PHASE_MESSAGES",
"PROGRESS_START",
"PROGRESS_IMAGE_DONE",
"PROGRESS_COPY_START",
"PROGRESS_COPY_STREAMING",
"PROGRESS_COPY_DONE",
"PROGRESS_REVIEW",
"PROGRESS_TRUST",
"PROGRESS_TTS",
"PROGRESS_VIDEO_START",
"PROGRESS_VIDEO_DONE",
"PROGRESS_UPLOAD_DONE",
"PROGRESS_DONE",
]
@@ -3,9 +3,10 @@
v1.6 重大简化:Seedance 2.5 单次最长30秒,单次调用直接出片,不再分段/拼接/ffmpeg concat。
状态机(三步分步):
pending -> running -> image_analyzed -> running -> copy_generated -> running -> completed
wait_user_confirm -> running -> completed (旧路径兼容)
任意阶段 fail; 任意非终态 cancel.
failed -> pending (retry 重置后重跑)。
意图解析(intent_parsing) 已并入脚本生成一步完成,不再单独作为阶段/状态存在。
"""
from __future__ import annotations
@@ -31,7 +32,6 @@ class ViralVideoStatus(StrEnum):
RUNNING = "running"
IMAGE_ANALYZED = "image_analyzed"
COPY_GENERATED = "copy_generated"
WAIT_USER_CONFIRM = "wait_user_confirm"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
@@ -40,7 +40,6 @@ class ViralVideoStatus(StrEnum):
class ViralVideoStage(StrEnum):
IMAGE_ANALYSIS = "image_analysis"
VIDEO_ANALYSIS = "video_analysis"
INTENT_PARSING = "intent_parsing"
SCRIPT_GENERATION = "script_generation" # v1.6: 编导分镜脚本(融合原 copy_fusion+storyboard+review)
REVIEW = "review"
TTS = "tts"
@@ -62,7 +61,6 @@ class StyleStrength(StrEnum):
class PromptType(StrEnum):
IMAGE_ANALYSIS = "image_analysis"
INTENT_PARSING = "intent_parsing"
SCRIPT_GENERATION = "script_generation"
REVIEW = "review"
VIDEO_STYLE_INTEGRATION = "video_style_integration"
@@ -72,7 +70,6 @@ class PromptType(StrEnum):
STAGE_LABELS = {
ViralVideoStage.IMAGE_ANALYSIS: "图片分析",
ViralVideoStage.VIDEO_ANALYSIS: "视频风格分析",
ViralVideoStage.INTENT_PARSING: "意图解析",
ViralVideoStage.SCRIPT_GENERATION: "编导脚本生成",
ViralVideoStage.REVIEW: "合规审核",
ViralVideoStage.TTS: "AI 配音",
@@ -112,7 +109,6 @@ class ViralVideoJob:
video_model: str = ""
# v1.4+ 产物
image_analysis: dict | None = None
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: 完整编导脚本结构
@@ -141,7 +137,6 @@ class ViralVideoJob:
ViralVideoStatus.PENDING,
ViralVideoStatus.IMAGE_ANALYZED,
ViralVideoStatus.COPY_GENERATED,
ViralVideoStatus.WAIT_USER_CONFIRM,
ViralVideoStatus.RUNNING,
):
raise ValueError(f"Cannot transition from {self.status} to running")
@@ -184,17 +179,10 @@ class ViralVideoJob:
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
self.intent_result = intent_result
self.updated_at = datetime.now(timezone.utc)
def resume_from_image_analyzed(self, **kwargs) -> None:
"""阶段2入口:允许从 IMAGE_ANALYZED/PENDING 首次进入,也允许从 COPY_GENERATED/COMPLETED/FAILED 重新生成文案。
重新生成时清空上一轮文案产物(copy_result/intent_result/storyboard/generated_copy_text),
重新生成时清空上一轮文案产物(copy_result/storyboard/generated_copy_text),
并重置 completed_at/result_video_url/error_msg,确保前端轮询能看到新的阶段2进度。
"""
_allowed = (
@@ -216,7 +204,6 @@ class ViralVideoJob:
setattr(self, k, v)
if _is_regen:
# 清空上一轮文案/视频产物,避免前端拿到旧数据
self.intent_result = None
self.copy_result = None
self.storyboard = None
self.generated_copy_text = ""
@@ -252,15 +239,6 @@ class ViralVideoJob:
self.completed_at = None
self.credits_cost = 0.0
def resume_from_confirm(self) -> None:
if self.status != ViralVideoStatus.WAIT_USER_CONFIRM:
raise ValueError(f"Cannot resume from {self.status}")
_now = datetime.now(timezone.utc)
self.started_at = _now
self.heartbeat_at = _now
self.status = ViralVideoStatus.RUNNING
self.updated_at = _now
def mark_completed(self, video_url: str) -> None:
self.status = ViralVideoStatus.COMPLETED
self.result_video_url = video_url
+167
View File
@@ -0,0 +1,167 @@
"""爆款视频领域统一异常体系(PR3 P2)。
所有爆款视频相关业务异常均继承 ViralVideoError,保证:
1. 上层可统一捕获,按 code/user_message 组装前端响应;
2. 不暴露内部栈/traceback;
3. http_status 供 API 层映射 HTTP 状态码。
"""
from __future__ import annotations
class ViralVideoError(Exception):
"""爆款视频业务异常基类。"""
code: str = "viral_video_error"
user_message: str = "视频生成失败,请稍后重试"
detail: str | None = None
http_status: int = 500
def __init__(
self,
user_message: str | None = None,
detail: str | None = None,
code: str | None = None,
http_status: int | None = None,
) -> None:
if user_message is not None:
self.user_message = user_message
self.detail = detail
if code is not None:
self.code = code
if http_status is not None:
self.http_status = http_status
super().__init__(self.user_message)
def __str__(self) -> str: # pragma: no cover
return f"[{self.code}] {self.user_message}" + (f" :: {self.detail}" if self.detail else "")
class ViralVideoConfigError(ViralVideoError):
"""配置/初始化错误:缺模型、缺 API Key、依赖未就绪等。"""
code = "config_error"
http_status = 500
def __init__(
self,
user_message: str = "服务配置异常,请稍后重试",
detail: str | None = None,
) -> None:
super().__init__(user_message=user_message, detail=detail)
class ViralVideoUpstreamError(ViralVideoError):
"""上游模型/云服务错误(火山 Seedance、阿里 Wan、OSS 等)。"""
code = "upstream_error"
http_status = 502
def __init__(
self,
provider: str,
upstream_code: str = "unknown",
user_message: str = "视频生成服务暂时不可用,请稍后重试",
detail: str | None = None,
status_code: int | None = None,
) -> None:
self.provider = provider
self.upstream_code = upstream_code
self.status_code = status_code
super().__init__(user_message=user_message, detail=detail)
class ViralVideoComplianceError(ViralVideoError):
"""合规审核不通过(用户文案命中敏感/违规内容)。"""
code = "compliance_rejected"
http_status = 400
def __init__(
self,
user_message: str = "文案未通过合规审核,请修改后重试",
issues: list | None = None,
detail: str | None = None,
) -> None:
self.issues: list = list(issues) if issues else []
super().__init__(user_message=user_message, detail=detail)
class ViralVideoResourceError(ViralVideoError):
"""资源不存在/失效:OSS URL 过期、图片找不到、返回空文件等。"""
code = "resource_error"
http_status = 502
def __init__(
self,
resource_type: str = "file",
resource_id: str = "",
user_message: str = "素材失效,请重新上传后重试",
detail: str | None = None,
) -> None:
self.resource_type = resource_type
self.resource_id = resource_id
super().__init__(user_message=user_message, detail=detail)
class ViralVideoCreditsError(ViralVideoError):
"""积分不足/结算失败。"""
code = "credits_insufficient"
http_status = 402
def __init__(
self,
user_message: str = "积分不足,请充值后重试",
detail: str | None = None,
) -> None:
super().__init__(user_message=user_message, detail=detail)
class ViralVideoTimeoutError(ViralVideoError):
"""某阶段执行超时(LLM/渲染/上传)。"""
code = "timeout"
http_status = 504
def __init__(
self,
stage: str,
timeout_s: float = 0.0,
user_message: str = "处理超时,请稍后重试",
detail: str | None = None,
) -> None:
self.stage = stage
self.timeout_s = timeout_s
super().__init__(user_message=user_message, detail=detail)
class ViralVideoLLMError(ViralVideoError):
"""LLM 调用失败/重试耗尽/解析失败。"""
code = "llm_error"
http_status = 502
def __init__(
self,
stage: str = "script_generation",
model: str = "",
user_message: str = "文案生成失败,请稍后重试",
detail: str | None = None,
) -> None:
self.stage = stage
self.model = model
super().__init__(user_message=user_message, detail=detail)
__all__ = [
"ViralVideoError",
"ViralVideoConfigError",
"ViralVideoUpstreamError",
"ViralVideoComplianceError",
"ViralVideoResourceError",
"ViralVideoCreditsError",
"ViralVideoTimeoutError",
"ViralVideoLLMError",
]
+68
View File
@@ -0,0 +1,68 @@
"""爆款视频阶段与进度点常量(PR3 P2)。
worker/API 中硬编码的阶段字符串与进度数值集中到此文件统一维护;
注意:Celery task name / HTTP path / WS event name 属对外契约,不在此列。
"""
from __future__ import annotations
# ── 阶段标识(对齐 ViralVideoStage 但更贴合 v1.6 实际链路) ──────────────
STAGE_IMAGE_ANALYSIS: str = "image_analysis"
STAGE_VIDEO_ANALYSIS: str = "video_analysis"
STAGE_COPY_GENERATION: str = "script_generation" # v1.6 文案=编导分镜
STAGE_REVIEW: str = "review"
STAGE_TRUST_CHAIN: str = "trust_chain" # #2172 信任链预热
STAGE_TTS: str = "tts"
STAGE_VIDEO_GENERATION: str = "rendering"
STAGE_UPLOADING: str = "uploading"
# ── 阶段中文提示 ────────────────────────────────────────────────────────
STAGE_PHASE_MESSAGES: dict[str, str] = {
STAGE_IMAGE_ANALYSIS: "正在分析图片内容...",
STAGE_VIDEO_ANALYSIS: "正在分析参考视频风格...",
STAGE_COPY_GENERATION: "正在生成文案与分镜...",
STAGE_REVIEW: "正在进行出片前合规审核...",
STAGE_TRUST_CHAIN: "正在AI化人像参考...",
STAGE_TTS: "正在合成AI配音...",
STAGE_VIDEO_GENERATION: "正在生成视频(约1-3分钟)...",
STAGE_UPLOADING: "正在上传成片...",
}
# ── 关键进度点(0-100)────────────────────────────────────────────────
PROGRESS_START: float = 0.0
PROGRESS_IMAGE_DONE: float = 10.0 # 图片分析完成
PROGRESS_COPY_START: float = 15.0 # 进入文案生成
PROGRESS_COPY_STREAMING: float = 30.0 # 文案流式输出中
PROGRESS_COPY_DONE: float = 45.0 # 文案生成完成
PROGRESS_REVIEW: float = 50.0 # 合规审核
PROGRESS_TRUST: float = 60.0 # 信任链 AI 化
PROGRESS_TTS: float = 65.0 # TTS 合成
PROGRESS_VIDEO_START: float = 70.0 # 开始视频生成
PROGRESS_VIDEO_DONE: float = 95.0 # 视频生成完成
PROGRESS_UPLOAD_DONE: float = 98.0 # 上传完成
PROGRESS_DONE: float = 100.0 # 全流程完成
__all__ = [
"STAGE_IMAGE_ANALYSIS",
"STAGE_VIDEO_ANALYSIS",
"STAGE_COPY_GENERATION",
"STAGE_REVIEW",
"STAGE_TRUST_CHAIN",
"STAGE_TTS",
"STAGE_VIDEO_GENERATION",
"STAGE_UPLOADING",
"STAGE_PHASE_MESSAGES",
"PROGRESS_START",
"PROGRESS_IMAGE_DONE",
"PROGRESS_COPY_START",
"PROGRESS_COPY_STREAMING",
"PROGRESS_COPY_DONE",
"PROGRESS_REVIEW",
"PROGRESS_TRUST",
"PROGRESS_TTS",
"PROGRESS_VIDEO_START",
"PROGRESS_VIDEO_DONE",
"PROGRESS_UPLOAD_DONE",
"PROGRESS_DONE",
]
+1 -1
View File
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
fi
# 共用 secrets 直接导出(如果存在)
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE DOUBAO_IMAGE_MODEL DOUBAO_IMAGE_SIZE DOUBAO_IMAGE_TIMEOUT DOUBAO_FAST_MODEL DOUBAO_TIMEOUT DOUBAO_MAX_RETRIES WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE DOUBAO_IMAGE_MODEL DOUBAO_IMAGE_SIZE DOUBAO_IMAGE_TIMEOUT DOUBAO_FAST_MODEL DOUBAO_TIMEOUT DOUBAO_MAX_RETRIES DITTO_EMOTION_TIMEOUT WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
for var in $SHARED_SECRETS; do
value="${!var:-}"
# 已经在环境中了,无需额外操作
+247
View File
@@ -0,0 +1,247 @@
"""Seed viral_video_param_guides 表(PR3 P2-h)。
将 worker 中硬编码的人设/结构/语言默认值灌入 DB,保证部署后查询有默认返回;
运营可后续在后台修改,30s TTL 缓存自动生效。
用法:
cd /root/work/xiaoxia-saas
PYTHONPATH=. python scripts/seed_viral_video_param_guides.py
幂等:用 ON CONFLICT (guide_type, guide_key) DO UPDATE 保证重复执行安全。
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from sqlalchemy import create_engine, text # noqa: E402
SEED_DATA: list[tuple[str, str, str]] = [
# ── persona:人设风格 ──────────────────────────────────────────────
("persona", "宝妈", "温柔贴心,多从带娃和家庭实用角度分享,语气亲切有共鸣"),
("persona", "店主", "热情实在,像当面招呼客人,突出靠谱和实在优惠"),
("persona", "专业顾问", "专业可信,讲清原理和效果,用事实打消顾虑"),
("persona", "年轻达人", "活泼有网感,节奏轻快,金句和梗自然不尬"),
("persona", "老板型IP", "以老板第一人称出镜,真诚接地气,像招呼街坊邻居一样分享,突出创业初心和靠谱"),
("persona", "知识博主", "条理清晰、数据说话,干货密度高,语气专业但不枯燥"),
("persona", "生活美学", "画面感强,注重氛围和质感描述,语速偏慢,文字有诗意"),
("persona", "健身教练", "energetic、鼓励式口吻,强调动作要领和效果变化"),
("persona", "美妆达人", "细腻讲质地和妆效,像闺蜜安利,语气亲切有感染力"),
("persona", "美食博主", "色香味描述丰富,口语化带馋感,节奏轻快"),
("persona", "穿搭博主", "讲搭配逻辑和场景适配,时尚但不高冷,像朋友建议"),
("persona", "育儿师", "科学育儿角度,温柔坚定,给具体可操作的建议"),
# ── structure:爆款结构 ────────────────────────────────────────────
(
"structure",
"反差破局+亮明观点+还原现状",
"开头3秒用反差/痛点钩子抓注意力,中段亮出核心卖点或观点,结尾还原真实到店/使用场景引导行动",
),
(
"structure",
"痛点切入+方案展示+效果对比",
"开头直击用户痛点场景,中间展示产品/服务解决方案,结尾用前后对比强化效果",
),
(
"structure",
"故事引入+产品种草+行动引导",
"用一个真实小故事/案例引入,自然过渡到产品种草,结尾明确引导用户下一步行动",
),
(
"structure",
"场景展示+价值输出+信任背书",
"开头展示使用场景让用户代入,中间输出核心价值主张,结尾用客户评价/数据等信任背书收尾",
),
("structure", "悬念开场+层层递进+高潮转化", "开头制造悬念引发好奇,内容层层推进保持张力,高潮处给出转化钩子"),
# ── language:语言提示 ─────────────────────────────────────────────
("language", "zh-cn", "使用标准普通话,口语化表达"),
("language", "zh-tw", "使用台湾腔中文,语气温柔亲切"),
("language", "zh-hk", "使用粤语风格中文表达"),
("language", "en-us", "使用美式英语,自然口语化"),
("language", "en-gb", "使用英式英语"),
("language", "ja-jp", "使用日语,自然口语化"),
("language", "ko-kr", "使用韩语,亲切自然"),
# ── marketing_purpose:营销目的(前端枚举 + 常见关键词,20项)─────────
(
"marketing_purpose",
"获客引流",
"【口播获客】第一人称对镜头发声,突出到店/加粉福利和行动引导,节奏紧凑有钩子,结尾明确CTA",
),
(
"marketing_purpose",
"账号涨粉",
"【涨粉内容】开头有强反差/悬念钩子抓注意力,内容有信息密度和共鸣点,结尾引导关注不生硬",
),
(
"marketing_purpose",
"活动通知",
"【活动通知】开头直接说活动主题和时间,中段讲福利/优惠力度,结尾强调截止时间和参与方式,信息清晰不绕弯",
),
(
"marketing_purpose",
"口播带货",
"【口播带货】第一人称对镜头说话,像真人出镜推荐好物,有真实使用体验感,突出卖点和使用效果,结尾有购买引导",
),
(
"marketing_purpose",
"促销转化",
"【促销转化】强调限时/限量/专属优惠,制造紧迫感,价格/优惠信息清晰突出,有明确购买路径",
),
(
"marketing_purpose",
"场景种草",
"【场景种草】在真实使用场景中展示产品/服务,自然带出卖点,让用户产生代入感和'我也想试试'的欲望",
),
(
"marketing_purpose",
"痛点解决",
"【痛点解决】开头直击目标用户的具体痛点场景,中段给出解决方案和使用演示,结尾强调效果对比",
),
(
"marketing_purpose",
"功能演示",
"【功能演示】以产品功能展示为主,操作步骤清晰可见,讲清楚'怎么用'和'用了有什么效果',少废话多演示",
),
(
"marketing_purpose",
"门店发现",
"【门店探店】店主或探店博主出镜,真实展示门店环境、招牌产品/服务、价格和体验感,结尾有到店引导",
),
(
"marketing_purpose",
"到店实录",
"【到店实录】真实记录到店体验全过程,镜头跟着主走进店、看环境、体验服务、尝产品,节奏自然真实",
),
(
"marketing_purpose",
"招牌体验",
"【招牌体验】聚焦门店/品牌的招牌产品/服务,细节展示特色卖点,突出'为什么这是招牌',有记忆点",
),
(
"marketing_purpose",
"同城团购",
"【同城团购】突出本地/同城/到店属性,讲清楚团购优惠内容、使用规则、门店位置,结尾引导下单",
),
(
"marketing_purpose",
"品牌主张",
"【品牌主张】传递品牌理念和价值观,有态度有情感共鸣,画面质感高级,少硬广多故事感",
),
(
"marketing_purpose",
"品牌故事",
"【品牌故事】用叙事方式讲品牌的创立初心/工艺传承/用户故事,有情感起伏,建立品牌信任",
),
(
"marketing_purpose",
"生活方式",
"【生活方式】营造向往的生活场景和氛围,产品/服务自然融入生活片段,调性好看有质感,引发'想过这样的生活'的共鸣",
),
(
"marketing_purpose",
"创意概念",
"【创意概念】画面有创意和视觉冲击力,概念新颖不套路,适合做品牌传播内容,让人愿意转发分享",
),
(
"marketing_purpose",
"对话短剧",
"【短剧模式】必须双/多角色对话格式,台词用'角色:xxx'格式,镜头在角色间切换,有剧情冲突或反转,禁止第三人称旁白解说",
),
(
"marketing_purpose",
"反转短剧",
"【反转短剧】前半段建立常规预期,后半段出人意料反转,对话推动剧情,节奏紧凑有爽感,结尾反转干脆利落",
),
(
"marketing_purpose",
"悬念短剧",
"【悬念短剧】开头制造悬念或谜题,剧情层层推进吊足胃口,结尾揭晓答案,角色对话推进,不要旁白解说",
),
(
"marketing_purpose",
"情绪短片",
"【情绪短片】用画面、音乐和简短台词传递情绪(治愈/感动/激励/共鸣),节奏偏慢有氛围感,文字克制留白",
),
# ── target_customer:目标客群(前端枚举 8项)─────────────────────
(
"target_customer",
"小镇青年",
"【目标小镇青年】台词接地气有网感,贴近下沉市场生活场景,讲实在优惠和真实体验,节奏轻快不端着",
),
(
"target_customer",
"小镇中老年",
"【目标小镇中老年】台词慢节奏、吐字清晰、信息直白,强调实惠耐用和健康养生,少用网络梗",
),
(
"target_customer",
"Z世代",
"【目标Z世代】台词活泼有梗、节奏快,可用流行语和弹幕感表达,突出个性、有趣、社交话题性",
),
(
"target_customer",
"精致妈妈",
"【目标精致妈妈】台词温柔贴心,从育儿/家庭/品质生活角度切入,强调安全、省心、性价比,有真实带娃/家居场景共鸣",
),
(
"target_customer",
"新锐白领",
"【目标新锐白领】台词简洁干练,突出效率、品质、格调,讲职场/都市生活场景,少废话直接说价值",
),
(
"target_customer",
"资深中产",
"【目标资深中产】台词专业有深度,强调品质、品位、身份感,调性高级克制,不浮夸不喊麦",
),
(
"target_customer",
"都市蓝领",
"【目标都市蓝领】台词实在接地气,强调性价比、耐用、实惠,讲打工/租房/日常刚需场景,贴近生活",
),
(
"target_customer",
"都市银发",
"【目标都市银发】台词清晰慢节奏,强调健康、实惠、方便操作,避免复杂术语和网络用语,信息直白",
),
]
def _get_engine():
"""获取 DB engine。优先 DATABASE_URL 环境变量;否则走应用配置(与 seed_viral_video_prompts.py 一致)。"""
import os
database_url = os.environ.get("DATABASE_URL")
if database_url:
url = database_url
else:
from packages.config import get_shared_settings
url = str(get_shared_settings().database_url)
# asyncpg → psycopg2(脚本里不能用 async driver)
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
if url.startswith("postgresql://") and "psycopg" not in url:
url = url.replace("postgresql://", "postgresql+psycopg2://", 1)
return create_engine(url, pool_pre_ping=True)
def main() -> None:
engine = _get_engine()
insert_sql = text("""
INSERT INTO viral_video_param_guides (guide_type, guide_key, guide_text)
VALUES (:gt, :gk, :gtxt)
ON CONFLICT (guide_type, guide_key)
DO UPDATE SET guide_text = EXCLUDED.guide_text, updated_at = NOW()
""")
count = 0
with engine.begin() as conn:
for gt, gk, gtxt in SEED_DATA:
conn.execute(insert_sql, {"gt": gt, "gk": gk, "gtxt": gtxt})
count += 1
print(f"[seed_viral_video_param_guides] upserted {count} rows")
if __name__ == "__main__":
main()
+17 -5
View File
@@ -39,12 +39,24 @@ class TestSplitSentences:
# ── Bug1 修复:无句末标点长文案按逗号二次切分 ──
def test_no_ending_punct_long_text_split_by_comma(self):
"""公益文案:全是逗号、结尾无标点,整段超过30字 → 逗号二次切分."""
text = "做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,心中有爱,眼里有光"
"""公益文案:几乎全是逗号、整段超过30字 → 逗号二次切分;短句合并后每段≥10字."""
text = "做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,心中有爱,眼里有光。"
sents = split_sentences(text)
assert len(sents) >= 3
assert any("心中有爱" in s for s in sents)
assert any("眼里有光" in s for s in sents)
assert len(sents) == 3
assert all(len(s) >= 10 for s in sents)
# "心中有爱,"(5字)与"眼里有光。"(5字)合并为一段(10字,语义完整)
assert sents[-1] == "心中有爱,眼里有光。"
def test_long_text_clause_count_bounded(self):
"""约100字多逗号长文案:短句合并后段数有界(4-8段而非12段),避免LLM超时."""
text = (
"做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,"
"心中有爱,眼里有光,手中有暖,脚下有路,每一份微小的善意都值得被看见,"
"每一个普通的坚持都能汇聚成改变世界的力量"
)
sents = split_sentences(text)
assert 3 <= len(sents) <= 8
assert all(len(s) >= 10 for s in sents)
def test_short_text_no_split(self):
"""不超过30字的无句末标点文案不二次切分."""
@@ -535,7 +535,6 @@ class TestViralVideoResumeForRegenerate:
# 模拟已经生成过文案和视频
job.status = ViralVideoStatus.COPY_GENERATED
job.copy_result = {"shots": [{"x": 1}], "voiceover_script": "旧文案"}
job.intent_result = {"intent": "旧意图"}
job.storyboard = [{"x": 1}]
job.generated_copy_text = "旧文案"
job.result_video_url = "http://old.mp4"
@@ -549,7 +548,6 @@ class TestViralVideoResumeForRegenerate:
assert job.status == ViralVideoStatus.RUNNING
assert job.copy_result is None
assert job.intent_result is None
assert job.storyboard is None
assert job.generated_copy_text == ""
assert job.result_video_url == ""
@@ -564,14 +562,12 @@ class TestViralVideoResumeForRegenerate:
job = ViralVideoJob(user_id="u1", images=["img1"])
job.status = ViralVideoStatus.COMPLETED
job.copy_result = {"shots": [], "voiceover_script": "xx"}
job.intent_result = {"intent": "x"}
job.result_video_url = "http://v.mp4"
job.resume_from_image_analyzed()
assert job.status == ViralVideoStatus.RUNNING
assert job.copy_result is None
assert job.intent_result is None
assert job.result_video_url == ""
def test_first_call_from_image_analyzed_keeps_fields(self):
@@ -588,14 +584,3 @@ class TestViralVideoResumeForRegenerate:
assert job.status == ViralVideoStatus.RUNNING
assert job.image_analysis == {"products": []}
assert job.industry == "美妆"
def test_wait_user_confirm_rejected(self):
"""wait_user_confirm 中间状态应被拒绝(前端正在编辑/确认文案)。"""
import pytest
from packages.domain.viral_video import ViralVideoJob, ViralVideoStatus
job = ViralVideoJob(user_id="u1", images=["img1"])
job.status = ViralVideoStatus.WAIT_USER_CONFIRM
with pytest.raises(ValueError, match="Cannot resume"):
job.resume_from_image_analyzed()
+2 -67
View File
@@ -34,7 +34,6 @@ class TestViralVideoStatus:
def test_status_values(self):
assert ViralVideoStatus.PENDING == "pending"
assert ViralVideoStatus.RUNNING == "running"
assert ViralVideoStatus.WAIT_USER_CONFIRM == "wait_user_confirm"
assert ViralVideoStatus.COMPLETED == "completed"
assert ViralVideoStatus.FAILED == "failed"
assert ViralVideoStatus.CANCELLED == "cancelled"
@@ -66,28 +65,6 @@ class TestViralVideoJobStateTransitions:
with pytest.raises(ValueError, match="Cannot transition"):
job.mark_running()
def test_mark_wait_user_confirm(self):
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
intent = {"intent": "推广", "key_messages": ["卖点1"]}
job.mark_wait_user_confirm(intent)
assert job.status == ViralVideoStatus.WAIT_USER_CONFIRM
assert job.intent_result == intent
def test_mark_wait_user_confirm_from_non_running_raises(self):
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.PENDING)
with pytest.raises(ValueError, match="Cannot transition"):
job.mark_wait_user_confirm({})
def test_resume_from_confirm(self):
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.WAIT_USER_CONFIRM)
job.resume_from_confirm()
assert job.status == ViralVideoStatus.RUNNING
def test_resume_from_non_confirm_raises(self):
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
with pytest.raises(ValueError, match="Cannot resume"):
job.resume_from_confirm()
def test_mark_completed(self):
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
job.mark_completed("https://oss.example.com/video.mp4")
@@ -140,7 +117,6 @@ class TestViralVideoStage:
expected_order = [
"image_analysis",
"video_analysis",
"intent_parsing",
"script_generation",
"review",
"tts",
@@ -157,46 +133,6 @@ class TestViralVideoStage:
class TestViralVideoSchemas:
"""Pydantic Schema 校验测试。"""
def test_create_request_valid(self):
from app.schemas.viral_video import CreateViralVideoRequest
req = CreateViralVideoRequest(images=["https://example.com/img.jpg"])
assert req.images == ["https://example.com/img.jpg"]
assert req.fusion_level == "ai_polish"
assert req.style_strength == "medium"
assert req.duration == 15
def test_create_request_empty_images_raises(self):
from app.schemas.viral_video import CreateViralVideoRequest
with pytest.raises(ValidationError):
CreateViralVideoRequest(images=[])
def test_create_request_invalid_fusion_level(self):
from app.schemas.viral_video import CreateViralVideoRequest
with pytest.raises(ValidationError):
CreateViralVideoRequest(
images=["https://example.com/img.jpg"],
fusion_level="invalid_level",
)
def test_create_request_invalid_style_strength(self):
from app.schemas.viral_video import CreateViralVideoRequest
with pytest.raises(ValidationError):
CreateViralVideoRequest(
images=["https://example.com/img.jpg"],
style_strength="ultra",
)
def test_confirm_intent_request_defaults(self):
from app.schemas.viral_video import ConfirmIntentRequest
req = ConfirmIntentRequest()
assert req.confirmed_copy == ""
assert req.adjustments == ""
def test_analyze_style_request(self):
from app.schemas.viral_video import AnalyzeStyleRequest
@@ -549,7 +485,7 @@ class TestPipelineIntegration:
):
"""v1.6: TTS整段合成 → 上传TTS到OSS → 单次 Seedance → 上传成片。"""
from apps.worker.worker_app.tasks.viral_video import (
resume_viral_video_pipeline,
run_viral_video_render,
)
job = ViralVideoJob(
@@ -557,7 +493,6 @@ class TestPipelineIntegration:
images=["https://img.com/1.jpg"],
industry="美妆",
status=ViralVideoStatus.RUNNING,
intent_result={"intent": "推广"},
duration=15,
video_ratio="9:16",
)
@@ -583,7 +518,7 @@ class TestPipelineIntegration:
mock_render.return_value = ("/tmp/video.mp4", {"completion_tokens": 1000000})
mock_upload.return_value = "https://oss.example.com/final.mp4"
result = resume_viral_video_pipeline.run("job-001")
result = run_viral_video_render.run("job-001")
assert result["ok"] is True
assert result["video_url"] == "https://oss.example.com/final.mp4"
+7 -17
View File
@@ -98,16 +98,6 @@ class TestScriptGenerationV16:
assert _safe_json_loads(None) is None
assert _safe_json_loads("not json") is None
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
@@ -299,19 +289,19 @@ class TestDoubaoClientVideoGen:
# ── P0-3: resume 从 job 读 image_analysis ────────────────────────
class TestResumeReadsImageAnalysis:
def test_resume_uses_persisted_image_analysis(self):
"""resume/render pipeline 应从 job.image_analysis 读(v1.5 _run_render_pipeline 共享渲染逻辑)。"""
class TestRenderReadsImageAnalysis:
def test_render_uses_persisted_image_analysis(self):
"""render pipeline 应从 job.copy_result 读(_run_render_pipeline 共享渲染逻辑)。"""
import inspect
from apps.worker.worker_app.tasks import viral_video as vv
# v1.5 改造后 resume 委托给 _run_render_pipeline,那里读取 job.image_analysis
# render task 委托给 _run_render_pipeline,那里读取 job.copy_result
src = inspect.getsource(vv._run_render_pipeline)
assert "job.copy_result" in src
# resume 本身应该调用 _run_render_pipeline
resume_src = inspect.getsource(vv.resume_viral_video_pipeline)
assert "_run_render_pipeline" in resume_src
# run_viral_video_render 本身应该调用 _run_render_pipeline
render_src = inspect.getsource(vv.run_viral_video_render)
assert "_run_render_pipeline" in render_src
# ============ PR1: _replace_henjin_everywhere 已删除(错别字改为 prompt NEGATIVE_RULES 禁止) ============
+213
View File
@@ -0,0 +1,213 @@
"""PR3 P2 爆款视频后端:errors/stages/param_guides 基础单测。"""
from __future__ import annotations
def test_errors_hierarchy():
from packages.domain.viral_video import (
ViralVideoComplianceError,
ViralVideoConfigError,
ViralVideoCreditsError,
ViralVideoError,
ViralVideoLLMError,
ViralVideoResourceError,
ViralVideoTimeoutError,
ViralVideoUpstreamError,
)
# 基类
e = ViralVideoError()
assert e.code == "viral_video_error"
assert e.http_status == 500
assert str(e)
# 上游错误
ue = ViralVideoUpstreamError("doubao", upstream_code="50411", user_message="模型不可用", status_code=400)
assert isinstance(ue, ViralVideoError)
assert ue.provider == "doubao"
assert ue.upstream_code == "50411"
assert ue.http_status == 502
assert ue.status_code == 400
# 合规错误带 issues
ce = ViralVideoComplianceError(user_message="审核未通过", issues=[{"text": "违规", "severity": "error"}])
assert ce.code == "compliance_rejected"
assert ce.http_status == 400
assert len(ce.issues) == 1
# 资源/积分/超时/LLM/配置
assert ViralVideoResourceError("video_file", "x").http_status == 502
assert ViralVideoCreditsError().http_status == 402
te = ViralVideoTimeoutError("rendering", timeout_s=180)
assert te.stage == "rendering"
assert te.http_status == 504
le = ViralVideoLLMError(stage="script", model="seed-2.1-lite", detail="max retries")
assert le.http_status == 502
assert le.model == "seed-2.1-lite"
assert ViralVideoConfigError().http_status == 500
def test_stages_constants():
from packages.domain.viral_video import (
PROGRESS_COPY_DONE,
PROGRESS_COPY_START,
PROGRESS_COPY_STREAMING,
PROGRESS_DONE,
PROGRESS_IMAGE_DONE,
PROGRESS_REVIEW,
PROGRESS_TRUST,
PROGRESS_TTS,
PROGRESS_UPLOAD_DONE,
PROGRESS_VIDEO_DONE,
PROGRESS_VIDEO_START,
STAGE_COPY_GENERATION,
STAGE_IMAGE_ANALYSIS,
STAGE_PHASE_MESSAGES,
STAGE_REVIEW,
STAGE_TRUST_CHAIN,
STAGE_TTS,
STAGE_UPLOADING,
STAGE_VIDEO_GENERATION,
)
assert STAGE_IMAGE_ANALYSIS == "image_analysis"
assert STAGE_COPY_GENERATION == "script_generation"
assert STAGE_REVIEW == "review"
assert STAGE_TRUST_CHAIN == "trust_chain"
assert STAGE_VIDEO_GENERATION == "rendering"
assert STAGE_UPLOADING == "uploading"
# 进度单调递增
assert PROGRESS_IMAGE_DONE < PROGRESS_COPY_START < PROGRESS_COPY_STREAMING < PROGRESS_COPY_DONE
assert PROGRESS_COPY_DONE < PROGRESS_REVIEW < PROGRESS_TRUST < PROGRESS_TTS < PROGRESS_VIDEO_START
assert PROGRESS_VIDEO_START < PROGRESS_VIDEO_DONE < PROGRESS_UPLOAD_DONE <= PROGRESS_DONE
# 阶段中文提示
assert "分析" in STAGE_PHASE_MESSAGES[STAGE_IMAGE_ANALYSIS]
assert "审核" in STAGE_PHASE_MESSAGES[STAGE_REVIEW]
def test_param_guides_invalid_type_returns_none():
from packages.application.viral_video.param_guides import get_param_guide
assert get_param_guide("bogus", "x") is None
assert get_param_guide("persona", "") is None
assert get_param_guide("persona", " ") is None
def test_fallback_script_honest_short(monkeypatch):
"""兜底文案必须短、诚实、基于真实 VLM 输入。"""
import sys
from unittest.mock import MagicMock
# mock celery/db 依赖
for mod in ["worker_app.celery_app", "worker_app.db"]:
monkeypatch.setitem(sys.modules, mod, MagicMock())
import celery
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
from worker_app.tasks import viral_video as wv
from packages.domain.viral_video import ViralVideoJob
# 有 name+brand
j = ViralVideoJob(user_id="u", duration=15, video_ratio="9:16")
j.image_analysis = {"images": [{"name": "口红", "brand": "XX牌", "type": "product"}]}
fb = wv._fallback_script(j)
assert "XX牌" in fb["voiceover_script"]
assert "口红" in fb["voiceover_script"]
assert len(fb["voiceover_script"]) < 20 # 非常短,不再是营销套话
assert fb["shots"] and len(fb["shots"]) == 1 # 只有一个镜头
assert fb["shots"][0]["time_range"] == "0-15秒"
# 只有 name
j2 = ViralVideoJob(user_id="u", duration=10)
j2.image_analysis = {"images": [{"name": "面馆", "type": "store"}]}
fb2 = wv._fallback_script(j2)
assert "来看看面馆" in fb2["voiceover_script"]
assert "都很赞" not in fb2["voiceover_script"] # 不再有营销套话
# 都没有
j3 = ViralVideoJob(user_id="u", duration=15)
j3.image_analysis = {"images": []}
fb3 = wv._fallback_script(j3)
assert "好东西" in fb3["voiceover_script"]
# user_copy_text 优先
j4 = ViralVideoJob(user_id="u", duration=15, user_copy_text="自定义文案")
j4.image_analysis = {"images": [{"name": "X", "brand": "Y"}]}
fb4 = wv._fallback_script(j4)
assert fb4["voiceover_script"] == "自定义文案"
def test_video_analysis_returns_none_on_import_error(monkeypatch):
"""ImportError 时返回 None,下游 isinstance(dict) 会跳过。"""
import sys
from unittest.mock import MagicMock
for mod in ["worker_app.celery_app", "worker_app.db"]:
monkeypatch.setitem(sys.modules, mod, MagicMock())
import celery
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
# 让 import viral_video.video_analyzer 抛 ImportError
import builtins
from worker_app.tasks import viral_video as wv
from packages.domain.viral_video import ViralVideoJob
real_import = builtins.__import__
def fake_import(name, *a, **kw):
if "video_analyzer" in name:
raise ImportError("no module")
return real_import(name, *a, **kw)
monkeypatch.setattr(builtins, "__import__", fake_import)
j = ViralVideoJob(user_id="u", reference_video_url="https://x/y.mp4")
res = wv._step_video_analysis(j)
assert res is None
def test_seedance_prompt_uses_helpers(monkeypatch):
"""_assemble_seedance_prompt 拆分后输出包含必要段落,且长度合理。"""
import sys
from unittest.mock import MagicMock, patch
for mod in ["worker_app.celery_app", "worker_app.db"]:
monkeypatch.setitem(sys.modules, mod, MagicMock())
import celery
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
from worker_app.tasks import viral_video as wv
from packages.domain.viral_video import ViralVideoJob
j = ViralVideoJob(user_id="u", duration=15, video_ratio="9:16", images=["https://x/1.jpg"])
copy_result = {
"overview": {"theme": "口红试色", "total_duration": 15, "aspect_ratio": "9:16"},
"scene_and_lighting": "暖光,室内美妆场景",
"shots": [
{
"time_range": "0-15秒",
"shot_type_angle_movement": "中景",
"voiceover": "这支口红颜色超正",
"reference_image_index": 0,
"transition": "结束",
}
],
"hard_constraints": ["无水印"],
"negative_prompts": ["字幕"],
}
with patch("packages.domain.points_rules.get_viral_video_model_config", return_value={"provider": "doubao"}):
with patch.object(wv, "_shot_visual", return_value="女生涂口红特写"):
with patch.object(wv, "_parse_shot_seconds", return_value=(0, 15)):
p = wv._assemble_seedance_prompt(copy_result, j)
assert "【视频总览】" in p
assert "【分镜脚本】" in p
assert "【硬性约束】" in p
assert "【负面提示词】" in p
assert "@图片1" in p
assert "口红试色" in p
+34 -100
View File
@@ -1,11 +1,12 @@
"""viral_video.py HTTP 端点单元测试(celery send_task 分支覆盖)。
直接调用路由函数(不启动 TestClient),通过 patch 注入 repo/session/user,
覆盖 4 个 celery_app.send_task(...) 调用点:
覆盖三步流程 celery_app.send_task(...) 调用点:
- create_viral_video (generate) -> worker.run_viral_video_pipeline
- retry_viral_video_job (retry) -> worker.run_viral_video_pipeline
- confirm_intent -> worker.resume_viral_video_pipeline
- analyze_images (analyze-images) -> worker.run_viral_video_analyze
- generate_copy (generate-copy) -> worker.run_viral_video_generate_copy
- confirm_copy (confirm-copy) -> worker.run_viral_video_render
- retry_viral_video_job (retry) -> worker.run_viral_video_analyze
- analyze_style -> worker.run_video_style_analysis
"""
@@ -61,7 +62,6 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"completed_at": None,
"stage": "",
"progress": 0.0,
"intent_result": None,
"image_analysis": None,
"storyboard": None,
"generated_copy_text": "",
@@ -86,42 +86,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
return job
# ── generate ────────────────────────────────────────────────────────────
class TestCreateViralVideo:
def _req(self, **kw):
from app.schemas.viral_video import CreateViralVideoRequest
d = {"images": ["https://x.com/a.jpg"], "industry": "电商", "target_customer": "年轻人"}
d.update(kw)
return CreateViralVideoRequest(**d)
def test_generate_dispatches_celery_task(self):
from app.api.routes import viral_video as vv_mod
req = self._req()
user = _auth_user("u1")
session = MagicMock()
saved_job = _make_job(job_id="job-new", user_id="u1", status="pending")
repo = MagicMock()
def fake_save(job):
job.id = saved_job.id
repo.save.side_effect = fake_save
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch.object(vv_mod.celery_app, "send_task") as mock_send,
):
resp = vv_mod.create_viral_video(req, authenticated_user=user, session=session)
mock_send.assert_called_once_with("worker.run_viral_video_pipeline", args=[saved_job.id])
assert resp.id == saved_job.id
# ── retry ───────────────────────────────────────────────────────────────
# ── retry ──────────────────────────────────────────────────────────────
class TestRetryViralVideo:
@@ -173,7 +138,7 @@ class TestRetryViralVideo:
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch("app.config.settings") as mock_settings,
patch("packages.domain.points_service.PointsService") as MockSvc,
patch("app.api.routes.viral_video.PointsService") as MockSvc,
patch.object(vv_mod.celery_app, "send_task"),
):
mock_settings.points_enabled = True
@@ -219,9 +184,9 @@ class TestRetryViralVideo:
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch("app.config.settings") as mock_settings,
patch("packages.domain.points_service.PointsService", return_value=fake_svc),
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(1920, 1080)),
patch("packages.domain.points_rules.calculate_viral_video_credits_with_breakdown", return_value=(15.0, {})),
patch("app.api.routes.viral_video.PointsService", return_value=fake_svc),
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(1920, 1080)),
patch("app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown", return_value=(15.0, {})),
patch.object(vv_mod.celery_app, "send_task"),
):
mock_settings.points_enabled = True
@@ -268,10 +233,10 @@ class TestRetryViralVideo:
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch("app.config.settings") as mock_settings,
patch("packages.domain.points_service.PointsService", return_value=fake_svc),
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(1920, 1080)),
patch("app.api.routes.viral_video.PointsService", return_value=fake_svc),
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(1920, 1080)),
patch(
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown", return_value=(new_est, {})
"app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown", return_value=(new_est, {})
),
patch.object(vv_mod.celery_app, "send_task"),
):
@@ -320,10 +285,10 @@ class TestRetryViralVideo:
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch("app.config.settings") as mock_settings,
patch("packages.domain.points_service.PointsService", return_value=fake_svc),
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(270, 480)),
patch("app.api.routes.viral_video.PointsService", return_value=fake_svc),
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(270, 480)),
patch(
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown", return_value=(new_est, {})
"app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown", return_value=(new_est, {})
),
patch.object(vv_mod.celery_app, "send_task"),
):
@@ -339,37 +304,6 @@ class TestRetryViralVideo:
assert call_args.kwargs["amount"] == 7.0
# ── confirm-intent ──────────────────────────────────────────────────────
class TestConfirmIntent:
def test_confirm_intent_dispatches_resume_task(self):
from app.api.routes import viral_video as vv_mod
from app.schemas.viral_video import ConfirmIntentRequest
from packages.domain.viral_video import ViralVideoStatus
user = _auth_user("u1")
session = MagicMock()
job = _make_job(job_id="job-cfm", user_id="u1", status=ViralVideoStatus.WAIT_USER_CONFIRM)
repo = MagicMock()
repo.get.return_value = job
req = ConfirmIntentRequest(confirmed_copy="确认后的文案")
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch.object(vv_mod.celery_app, "send_task") as mock_send,
):
resp = vv_mod.confirm_intent("job-cfm", req, authenticated_user=user, session=session)
assert job.user_copy_text == "确认后的文案"
job.resume_from_confirm.assert_called_once()
mock_send.assert_called_once_with("worker.resume_viral_video_pipeline", args=["job-cfm"])
assert resp.id == "job-cfm"
# ── analyze-style ───────────────────────────────────────────────────────
class TestAnalyzeStyle:
def test_analyze_style_dispatches_analysis_task(self):
@@ -474,7 +408,7 @@ class TestGenerateCopy:
assert resp.id == "job-gc"
def test_generate_copy_rejects_wrong_status(self):
"""wait_user_confirm 等中间状态不允许调用 generate-copy(状态保护)。"""
"""cancelled 等非预期状态不允许调用 generate-copy(状态保护)。"""
import pytest
from app.api.routes import viral_video as vv_mod
from app.schemas.viral_video import GenerateCopyRequest
@@ -484,8 +418,8 @@ class TestGenerateCopy:
user = _auth_user("u1")
session = MagicMock()
# wait_user_confirm 属于前端在编辑/确认文案的中间状态,应拒绝重新触发生成
job = _make_job(job_id="job-gc2", user_id="u1", status=ViralVideoStatus.WAIT_USER_CONFIRM)
# cancelled 状态下不应触发生成文案
job = _make_job(job_id="job-gc2", user_id="u1", status=ViralVideoStatus.CANCELLED)
repo = MagicMock()
repo.get.return_value = job
@@ -663,9 +597,9 @@ class TestConfirmCopyPointsDeduction:
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch.object(vv_mod, "_settings", None, create=True), # ensure not cached
patch("app.config.settings") as mock_settings,
patch("packages.domain.points_rules.calculate_viral_video_credits", return_value=5.2),
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
patch("packages.domain.points_service.PointsService", return_value=mock_svc),
patch("app.api.routes.viral_video.calculate_viral_video_credits", return_value=5.2),
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(720, 1280)),
patch("app.api.routes.viral_video.PointsService", return_value=mock_svc),
patch.object(vv_mod.celery_app, "send_task"),
):
mock_settings.points_enabled = True
@@ -706,9 +640,9 @@ class TestConfirmCopyPointsDeduction:
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch("app.config.settings") as mock_settings,
patch("packages.domain.points_rules.calculate_viral_video_credits", return_value=10.0),
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
patch("packages.domain.points_service.PointsService", return_value=mock_svc),
patch("app.api.routes.viral_video.calculate_viral_video_credits", return_value=10.0),
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(720, 1280)),
patch("app.api.routes.viral_video.PointsService", return_value=mock_svc),
patch.object(vv_mod.celery_app, "send_task"),
):
mock_settings.points_enabled = True
@@ -746,7 +680,7 @@ class TestConfirmCopyPointsDeduction:
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch("app.config.settings") as mock_settings,
patch("packages.domain.points_service.PointsService") as MockSvc,
patch("app.api.routes.viral_video.PointsService") as MockSvc,
patch.object(vv_mod.celery_app, "send_task") as mock_send,
):
mock_settings.points_enabled = True
@@ -783,7 +717,7 @@ class TestConfirmCopyPointsDeduction:
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch("app.config.settings") as mock_settings,
patch("packages.domain.points_service.PointsService") as MockSvc,
patch("app.api.routes.viral_video.PointsService") as MockSvc,
patch.object(vv_mod.celery_app, "send_task"),
):
mock_settings.points_enabled = True
@@ -808,7 +742,7 @@ class TestConfirmCopyPointsDeduction:
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch("app.config.settings") as mock_settings,
patch("packages.domain.points_service.PointsService") as MockSvc,
patch("app.api.routes.viral_video.PointsService") as MockSvc,
patch.object(vv_mod.celery_app, "send_task"),
):
mock_settings.points_enabled = False
@@ -863,9 +797,9 @@ class TestEstimateCredits:
}
with (
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(1920, 1080)) as mock_res,
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(1920, 1080)) as mock_res,
patch(
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
"app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown",
return_value=(8.88, fake_bd),
) as mock_calc,
):
@@ -902,9 +836,9 @@ class TestEstimateCredits:
}
with (
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(720, 1280)),
patch(
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
"app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown",
return_value=(3.5, fake_bd),
) as mock_calc,
):
@@ -936,9 +870,9 @@ class TestEstimateCredits:
}
with (
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(480, 480)) as mock_res,
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(480, 480)) as mock_res,
patch(
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
"app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown",
return_value=(1.0, fake_bd),
) as mock_calc,
):
+4 -12
View File
@@ -51,7 +51,6 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"completed_at": None,
"stage": "",
"progress": 0.0,
"intent_result": None,
"image_analysis": None,
"storyboard": None,
"copy_result": {"voiceover_script": "测试口播", "shots": [{"clip_id": 1}]},
@@ -228,9 +227,9 @@ class TestBug1ConfirmCopyParams:
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch("app.config.settings") as mock_settings,
patch("packages.domain.points_rules.calculate_viral_video_credits", return_value=15.0),
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
patch("packages.domain.points_service.PointsService", return_value=mock_svc),
patch("app.api.routes.viral_video.calculate_viral_video_credits", return_value=15.0),
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(720, 1280)),
patch("app.api.routes.viral_video.PointsService", return_value=mock_svc),
patch.object(vv_mod.celery_app, "send_task"),
):
mock_settings.points_enabled = True
@@ -966,7 +965,7 @@ class TestIssue2249UserParamsFix:
def test_bug2_persona_style_guide_covers_boss_ip(self):
"""Bug2: _PERSONA_STYLE_GUIDE 必须覆盖'老板型IP'"""
from apps.worker.worker_app.tasks.viral_video import _PERSONA_STYLE_GUIDE
from apps.worker.worker_app.tasks.viral_video import _DEFAULT_PERSONA_HINTS as _PERSONA_STYLE_GUIDE
assert "老板型IP" in _PERSONA_STYLE_GUIDE
assert "老板" in _PERSONA_STYLE_GUIDE["老板型IP"] or "第一人称" in _PERSONA_STYLE_GUIDE["老板型IP"]
@@ -1050,13 +1049,6 @@ class TestIssue2249UserParamsFix:
assert hasattr(job, "language")
assert job.language == "zh-CN"
def test_bug5_language_in_create_request(self):
"""Bug5: CreateViralVideoRequest 接受 language"""
from apps.api.app.schemas.viral_video import CreateViralVideoRequest
req = CreateViralVideoRequest(images=["http://a.jpg"], language="en-US")
assert req.language == "en-US"
def test_bug5_language_in_generate_copy_request(self):
"""Bug5: GenerateCopyRequest 接受 language"""
from apps.api.app.schemas.viral_video import GenerateCopyRequest
-2
View File
@@ -209,7 +209,6 @@ class TestReviewWiring:
"voiceover_script": "这款口红显白又持久",
"shots": [{"scene_and_dialogue": "展示口红"}],
}
job.intent_result = {"key_messages": ["显白", "持久"], "intent": "推广"}
pass_result = ReviewResult(passed=True, score=90, issues=[], rewrite_suggestions=[])
with patch.object(Reviewer, "review", return_value=pass_result):
@@ -228,7 +227,6 @@ class TestReviewWiring:
"voiceover_script": "这款口红100%有效",
"shots": [{"scene_and_dialogue": "展示"}],
}
job.intent_result = {"key_messages": ["显白"], "intent": "推广"}
fail_result = ReviewResult(
passed=False,
+5 -27
View File
@@ -106,31 +106,11 @@ class TestWorkerEmitProgress:
assert parsed["type"] == "viral_video:failed"
assert parsed["data"]["error"] == "oom"
def test_emit_progress_wait_user_event(self):
fake_r = MagicMock()
with patch("redis.from_url", return_value=fake_r):
worker_vv._emit_progress(
"job-4",
"intent_parsing",
35,
message="waiting for you",
event_type="viral_video:wait_user",
)
_, payload = fake_r.publish.call_args.args
parsed = json.loads(payload)
assert parsed["type"] == "viral_video:wait_user"
# ---------------------------------------------------------------------------
# Pure helpers on the route module
# ---------------------------------------------------------------------------
class TestWSHelpers:
def test_estimate_progress_maps_status(self):
assert vv_module._estimate_progress(_make_job(status="pending")) == 0.0
assert vv_module._estimate_progress(_make_job(status="running")) == 5.0
assert vv_module._estimate_progress(_make_job(status="wait_user_confirm")) == 35.0
assert vv_module._estimate_progress(_make_job(status="completed")) == 100.0
assert vv_module._estimate_progress(_make_job(status="failed")) == 0.0
@@ -140,8 +120,6 @@ class TestWSHelpers:
assert isinstance(msg, str) and msg
job_failed = _make_job(status="failed", error_msg="boom")
assert "boom" in vv_module._initial_message(job_failed)
job_wait = _make_job(status="wait_user_confirm")
assert "等待" in vv_module._initial_message(job_wait)
def test_stage_from_status_falls_back(self):
assert isinstance(vv_module._stage_from_status(_make_job(status="pending")), str)
@@ -325,16 +303,16 @@ class TestWSInitialSnapshot:
# Session was used for both ownership check and initial snapshot.
assert sess.close.call_count >= 2
def test_running_job_with_enum_status(self):
def test_image_analyzed_job_sends_snapshot(self):
job = _make_job(
status=SimpleNamespace(value="wait_user_confirm"),
status=SimpleNamespace(value="image_analyzed"),
user_id="user-a",
is_terminal=False,
)
received, _, _ = _run_ws_handshake(job=job)
assert received[0]["data"]["status"] == "wait_user_confirm"
assert received[0]["progress"] == 35.0
assert "等待" in received[0]["message"]
assert received[0]["data"]["status"] == "image_analyzed"
assert received[0]["progress"] == 15.0
assert "图片分析" in received[0]["message"]
def test_already_completed_job_sends_completion_event_and_closes(self):
job = _make_job(