Compare commits

...

9 Commits

Author SHA1 Message Date
xiaoxia 3ee5a4042d Merge pull request 'feat: 提示词控制展示格式 - summary_markdown + copy_display_markdown' (#2236) from feature/prompt-controlled-display-format 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 (push) Successful in 1s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 30s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m8s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 47s
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 / PR Build Worker Image (pull_request) Successful in 2m21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 56s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m11s
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) Successful in 4m0s
AI Code Review / AI Code Review (pull_request) Successful in 7m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m38s
CI/CD Pipeline / Integration Tests (push) Successful in 13m54s
CI/CD Pipeline / Validate - Style (push) Successful in 14m31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 17m46s
CI/CD Pipeline / Unit Tests (push) Failing after 20m24s
CI/CD Pipeline / Validate - Security (push) Successful in 35m30s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-10-07 19:46:29 +08:00
Xiaoxia Agent a979af1488 feat(web): ViralVideoPage markdown 渲染 summary_markdown + copy_display_markdown
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 / 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 1m29s
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 1m52s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m56s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m15s
AI Code Review / AI Code Review (pull_request) Successful in 7m2s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 10m1s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 10m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 12m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m42s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 16m58s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m2s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 2m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 32m52s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 4s
- 引入 marked 轻量 markdown 库(gfm)
- 识别描述汇览:有 summary_markdown 时渲染 markdown,否则 fallback 到现有 vv-recog-line
- 分镜脚本区:新增文案预览区渲染 copy_display_markdown,编辑交互不变
- types 补充 summary_markdown / copy_display_markdown 字段
- 新增 vv-md-body markdown 排版样式
2026-10-07 19:24:43 +08:00
CI Bot 77e19a6b44 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / 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 API Image (pull_request) Successful in 59s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m19s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m37s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m31s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m40s
AI Code Review / AI Code Review (pull_request) Successful in 7m5s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 14m19s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m39s
2026-10-07 11:07:14 +00:00
Xiaoxia Agent e4c3f9a046 ci: re-trigger CI pipeline
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 API Image (pull_request) Successful in 57s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 59s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m39s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m14s
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 / Validate - Security (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
2026-10-07 19:02:34 +08:00
Xiaoxia Agent 902effc1f9 feat: 提示词控制展示格式 - summary_markdown + copy_display_markdown
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / 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) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / PR Build API Image (pull_request) Successful in 54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m33s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m32s
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 - 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 / Unit 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
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
- assembler.py: 新增 _build_summary_markdown 兜底函数,VLM未返回时根据结构化字段生成markdown
- reviewer.py: markdown展示字段不参与合规审核(避免格式字符误判)
- viral_video.py: _script_from_xml 提取 copy_display_markdown 字段
- migration 104: v8 image_analysis prompt(新增summary_markdown输出要求)+ v3 storyboard prompt(新增copy_display_markdown输出要求)
- v8/v3 设为active,v7/v2 停用
2026-10-07 18:45:39 +08:00
CI Bot a408cfdc97 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging 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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 12s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 13s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
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 (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 49s
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m58s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m27s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Integration Tests (push) Successful in 5m6s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m43s
CI/CD Pipeline / Build Staging Web 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 6m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m34s
AI Code Review / AI Code Review (pull_request) Successful in 7m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 8m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m21s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m2s
CI/CD Pipeline / Validate - Security (push) Successful in 11m9s
CI/CD Pipeline / Unit Tests (push) Failing after 12m30s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 25m16s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-10-07 09:46:34 +00:00
xiaoxia 9464322710 Merge pull request 'fix: PR#2233 followup - 修复3个线上bug' (#2234) from fix/pr2233-followup into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 8s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m18s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / 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 / PR Build API Image (pull_request) Successful in 1m35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m54s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 5m48s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m6s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m38s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (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 / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
2026-10-07 17:37:26 +08:00
xiaoxia aa1f318308 feat(lipsync): 对接蚂蚁 Ditto 数字人 API 替换 MuseTalk 口型(#2076) (#2235)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 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 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 24s
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 Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 40s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API 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 E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging 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 cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / 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-07 17:35:58 +08:00
Xiaoxia Agent 3a59948f53 fix: PR#2233 followup - 修复3个线上bug
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m2s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m3s
AI Code Review / AI Code Review (pull_request) Successful in 7m0s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 10m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 11m27s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) 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 8m27s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 45s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 49s
Bug1: ai_client.py 日志格式化TypeError
- timeout=%d 改为 timeout=%s
- 传 getattr(_req_timeout, "read", _req_timeout) 提取数值

Bug2: assembler.py store分支name兜底太激进
- 门头图name不再用brand(避免与brand字段重复显示)
- store_type有具体值时用store_type
- store_type为默认"店铺"时用"门店门头"

Bug3: reviewer超时后未正确降级放行
- reviewer.py: LLM审核失败时返回passed=True(降级放行)
- reviewer.py: _llm_review加try/except捕获异常返回None
- viral_video.py: passed=False但issues为空时(超时导致),降级放行

分支: fix/pr2233-followup
2026-10-07 17:21:19 +08:00
18 changed files with 1477 additions and 76 deletions
@@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
"""image_analysis v8 prompt + storyboard v3 prompt - 用户端展示格式 markdown 控制
Revision ID: 104_v8_display_markdown
Revises: 103_v7_prompt_and_tokens_3000
Create Date: 2026-10-07
变更:
1. image_analysis v8: 在 v7 基础上 system_prompt 末尾追加「## 用户端展示格式」章节,
要求 VLM 在每张图的 JSON 里输出 summary_markdown 字段(markdown 格式的图片描述),
v8 设 is_active=true,v7 设 is_active=false。
2. storyboard v3: 在 v2 基础上 system_prompt 追加要求 LLM 在 copy_result 中
输出 copy_display_markdown 字段(markdown 格式的完整文案展示),
v3 设 is_active=true,v2 设 is_active=false。
"""
from sqlalchemy import text
from alembic import op
revision = "104_v8_display_markdown"
down_revision = "103_v7_prompt_and_tokens_3000"
branch_labels = None
depends_on = None
# ── v8 追加的 system prompt 内容 ──────────────────────────────────────
V8_SYSTEM_APPEND = """
## 用户端展示格式
对于每张分析的图片,在 JSON 中额外输出一个 **summary_markdown** 字段,用 markdown 格式写出给用户看的图片描述。
格式要求(根据图片类型自适应):
**商品图(type=product)**示例:
### 商品名称
**品牌**:品牌名 | **类目**:服饰鞋包/美妆/数码/...
**核心特征**
- 特征1:描述
- 特征2:描述
**外观**:颜色+材质+设计描述
**包装**:包装类型描述
**文字信息**:包装上看到的文字
**门店场景图(type=store)**示例:
### 门店名称/类型
**类型**:奶茶店/便利店/养生馆/...
**品牌标识**:招牌文字描述
**环境氛围**:店内整体感觉
**陈列亮点**
- 亮点1
- 亮点2
**氛围**:亲民/专业/时尚/...
**人物图(type=person)**示例:
### 人物描述
**形象**:年龄段 + 风格
**穿搭**
- 上装:颜色+款式
- 下装:颜色+款式
- 配饰:...
**气质**:表情+姿势+整体感觉
**风景/场景图(type=scene)**示例:
### 场景名称
**类型**:自然风景/城市街景/动物/美食
**主体**:画面主要元素
**氛围**:整体感觉描述
要求:
- 内容真实具体,从实际图片分析得出
- 用 markdown 语法:**加粗**、列表、标题
- 控制在 100-200 字
- 不要编造图片中没有的信息
"""
# ── storyboard v3 追加的 system prompt 内容 ──────────────────────────
V3_STORYBOARD_APPEND = """
## 用户端展示格式
在输出分镜脚本的同时,在顶层输出一个 **copy_display_markdown** 字段(用 XML 标签 <copy_display_markdown> 包裹),用 markdown 格式写出完整文案展示。
格式示例:
# 标题/主题
## 整体概要
一句话描述视频内容
## 分镜预览
### 镜头1(0-3秒)
**景别**:近景俯拍,缓慢推镜
**画面**:场景描述
**台词**:口播文本
**动作**:人物动作描述
### 镜头2(3-9秒)
...
## 完整口播
完整口播文案文本
要求:
- 把所有分镜按时间顺序整理成易读的格式
- 用 markdown 语法组织,**加粗**标签、##二级标题、列表等
- 控制在 300-500 字
- 让用户一眼看懂视频会拍成什么样
"""
def upgrade() -> None:
bind = op.get_bind()
# ── 1. image_analysis v8 ──────────────────────────────────────────
# 停用所有 active image_analysis prompt
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
"WHERE prompt_type = 'image_analysis' AND is_active = TRUE"
)
)
# 读取 v7 的 prompt 内容作为基础
v7_row = bind.execute(
text(
"SELECT system_prompt, user_prompt_template, COALESCE(example_output, '') "
"FROM viral_video_prompt_templates "
"WHERE prompt_type = 'image_analysis' "
"ORDER BY version DESC LIMIT 1"
)
).fetchone()
if v7_row:
v7_system = v7_row[0] or ""
v8_system = v7_system + V8_SYSTEM_APPEND
v8_user = v7_row[1] or "{image_url}"
v8_example = v7_row[2] or ""
# 幂等:已有 v8 则更新,否则插入
existing_v8 = bind.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 8")
).fetchone()
if existing_v8:
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
"system_prompt = :sys, user_prompt_template = :usr, "
"example_output = :ex, name = 'v8 用户端展示格式', "
"updated_at = NOW() "
"WHERE prompt_type = 'image_analysis' AND version = 8"
),
{"sys": v8_system, "usr": v8_user, "ex": v8_example},
)
else:
bind.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(prompt_type, version, name, system_prompt, user_prompt_template, "
"example_output, is_active, created_at, updated_at) "
"VALUES ('image_analysis', 8, 'v8 用户端展示格式', "
":sys, :usr, :ex, TRUE, NOW(), NOW())"
),
{"sys": v8_system, "usr": v8_user, "ex": v8_example},
)
# ── 2. storyboard v3 ─────────────────────────────────────────────
# 停用所有 active storyboard prompt
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
"WHERE prompt_type = 'storyboard' AND is_active = TRUE"
)
)
# 读取当前 storyboard prompt
sb_row = bind.execute(
text(
"SELECT system_prompt, user_prompt_template, COALESCE(example_output, '') "
"FROM viral_video_prompt_templates "
"WHERE prompt_type = 'storyboard' "
"ORDER BY version DESC LIMIT 1"
)
).fetchone()
if sb_row:
sb_system = sb_row[0] or ""
v3_system = sb_system + V3_STORYBOARD_APPEND
v3_user = sb_row[1] or ""
v3_example = sb_row[2] or ""
existing_v3 = bind.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'storyboard' AND version = 3")
).fetchone()
if existing_v3:
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
"system_prompt = :sys, user_prompt_template = :usr, "
"example_output = :ex, name = 'v3 用户端展示格式', "
"updated_at = NOW() "
"WHERE prompt_type = 'storyboard' AND version = 3"
),
{"sys": v3_system, "usr": v3_user, "ex": v3_example},
)
else:
bind.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(prompt_type, version, name, system_prompt, user_prompt_template, "
"example_output, is_active, created_at, updated_at) "
"VALUES ('storyboard', 3, 'v3 用户端展示格式', "
":sys, :usr, :ex, TRUE, NOW(), NOW())"
),
{"sys": v3_system, "usr": v3_user, "ex": v3_example},
)
def downgrade() -> None:
bind = op.get_bind()
# 删除 v8
bind.execute(
text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 8")
)
# 恢复 v7 active
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, updated_at = NOW() "
"WHERE prompt_type = 'image_analysis' AND version = 7"
)
)
# 删除 v3
bind.execute(text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'storyboard' AND version = 3"))
# 恢复 storyboard v2 active
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, updated_at = NOW() "
"WHERE prompt_type = 'storyboard' AND version = 2"
)
)
+41 -1
View File
@@ -223,7 +223,47 @@ class LipsyncService:
if timings:
job.sentence_timings = timings
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
# 4. 检查是否走 Ditto(蚂蚁数字人,#2076):开关 + 配置完整
use_ditto = False
if self.settings.use_ditto_lipsync:
try:
from packages.application.ditto_service import get_ditto_client
ditto = get_ditto_client()
if ditto.is_configured:
use_ditto = True
logger.info("[lipsync] 优先走 Ditto 蚂蚁数字人: job_id=%s", job.id)
else:
logger.info(
"[lipsync] Ditto 开关已开但配置不完整(base_url=%s, template=%s),继续判断 GPU: job_id=%s",
bool(ditto.base_url),
bool(ditto.default_video_url),
job.id,
)
except Exception as exc:
logger.warning("[lipsync] Ditto 初始化失败,继续判断 GPU: job_id=%s err=%s", job.id, exc)
if use_ditto:
try:
# Ditto 使用预置人物模板视频,不用用户上传的 video_url;
# 但保留用户 video_url 以便失败回退到 GPU/MediaKit。
job.status = "processing"
job.mediakit_task_id = "ditto:submitted"
job.updated_at = datetime.now(UTC)
self.db.commit()
from app.tasks.lipsync_ditto import lipsync_ditto_process_async
lipsync_ditto_process_async.apply_async(args=(job.id, job.user_id))
logger.info("[lipsync] Ditto 任务已异步派发: job_id=%s", job.id)
return
except Exception as exc:
logger.warning("[lipsync] Ditto 派发失败,回退 GPU/MediaKit: job_id=%s err=%s", job.id, exc)
try:
self.db.rollback()
except Exception:
pass
# 5. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
use_gpu = False
if self.settings.use_gpu_lipsync:
try:
+318
View File
@@ -0,0 +1,318 @@
"""Ditto 蚂蚁数字人口型异步任务 — #2076.
把 Ditto 同步 HTTP 调用(30-120s)从 API 请求移到 Celery 后台执行:
1. 加载 LipsyncJob
2. 调 DittoClient.generate_and_persist(video_url=默认模板, audio_url=job.audio_url, script=job.script_text)
3. 成功:标记 completed,写入 output_video_url(Ditto 输出自带音频,无需二次混流/超分)
4. 失败:回退 GPU MuseTalk → 再失败回退 MediaKit
注意:
- 保留 MuseTalk 代码不动;Ditto 优先,失败按原链路兜底
- Ditto 使用预置的人物模板视频(settings.ditto_default_video_url),不用用户上传的 video_url
- 不传 GFPGAN 超分,不需要 ffmpeg 音视频混流
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Optional
from celery import shared_task
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
logger = logging.getLogger(__name__)
_DITTO_URL_TTL_SECONDS = 7 * 24 * 3600 # Ditto 结果 OSS URL 7 天有效
def _get_db_session() -> Session:
try:
from worker_app.db import SessionLocal # type: ignore
except ImportError:
from app.db import SessionLocal # type: ignore
return SessionLocal()
def _sign_media_url(url: str) -> str:
"""对自家 OSS URL 签 7 天预签名。"""
if not url:
return url
try:
from urllib.parse import urlparse
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base:
return url
own_host = urlparse(public_base).netloc.lower()
host = urlparse(url).netloc.lower()
if not own_host or host != own_host:
return url
return storage.get_download_url(url, expires_seconds=_DITTO_URL_TTL_SECONDS)
except Exception:
return url
def _probe_video_duration(video_bytes: bytes) -> float:
"""用 ffprobe 探测视频时长(秒);失败返回 0。"""
try:
import os
import subprocess
import tempfile
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
tmp.write(video_bytes)
tmp_path = tmp.name
try:
out = subprocess.check_output(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
tmp_path,
],
stderr=subprocess.STDOUT,
timeout=10,
)
return float(out.decode().strip() or 0)
finally:
os.unlink(tmp_path)
except Exception as exc:
logger.warning("[ditto_task] ffprobe 失败: %s", exc)
return 0.0
def _refund_lip_sync(db: Session, job: "LipsyncJobModel") -> None:
"""Ditto 失败/取消时全额退款(复用 lipsync_service 的退款逻辑)。"""
try:
from app.services.lipsync_service import LipsyncService
LipsyncService(db)._refund_lip_sync(job)
except Exception:
logger.exception("[ditto_task] lip_sync 退款异常 job_id=%s", job.id)
def _settle_lip_sync(db: Session, job: "LipsyncJobModel", duration: float) -> None:
"""Ditto 成功后按实际时长结算。"""
try:
from app.services.lipsync_service import LipsyncService
LipsyncService(db)._settle_lip_sync(job, duration)
except Exception:
logger.exception("[ditto_task] lip_sync 结算异常 job_id=%s(不阻塞)", job.id)
def _fallback_to_gpu_then_mediakit(db: Session, job: "LipsyncJobModel") -> None:
"""Ditto 失败后:优先回退 GPU MuseTalk,再回退 MediaKit 云端。
复用 lipsync_service 现有路径逻辑以保证兜底一致性。
"""
# 先尝试走 GPU MuseTalk(若可用)
try:
from app.services.gpu_lipsync_service import GpuLipsyncService
from app.tasks.lipsync_gpu import lipsync_gpu_process_async
gpu_svc = GpuLipsyncService(db)
if gpu_svc.has_available_worker():
logger.info("[ditto_task] 回退 GPU MuseTalk: job_id=%s", job.id)
# 复用 lipsync_service._submit_to_gpu_create 逻辑
from app.services.lipsync_service import LipsyncService
svc = LipsyncService(db)
storage = _shared_storage()
persisted_audio = None
try:
persisted_audio = svc._persist_external_audio_for_gpu(job=job, storage=storage)
except Exception as exc:
logger.warning("[ditto_task] GPU 外部音频转存失败: %s", exc)
audio_url_for_task = persisted_audio or job.audio_url
gpu_task = gpu_svc.create_task(
video_url=job.video_url,
audio_url=audio_url_for_task,
lipsync_job_id=job.id,
user_id=job.user_id,
)
if gpu_task is not None:
job.mediakit_task_id = f"gpu:{gpu_task.id}"
job.status = "processing"
job.updated_at = datetime.now(UTC)
db.commit()
lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id))
return
db.rollback()
except Exception as exc:
logger.warning("[ditto_task] GPU MuseTalk 回退失败,转 MediaKit: %s", exc)
try:
db.rollback()
except Exception:
pass
# 最后兜底:MediaKit 云端
try:
from app.services.mediakit_client import get_mediakit_client
client = get_mediakit_client()
video_url = _sign_media_url(job.video_url)
signed_audio_url = _sign_media_url(job.audio_url)
result = client.submit_lipsync(
video_url=video_url,
audio_url=signed_audio_url,
enable_video_loop=job.enable_video_loop,
client_token=job.id,
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info("[ditto_task] 已回退 MediaKit: job_id=%s task_id=%s", job.id, result["task_id"])
except Exception as exc:
job.status = "failed"
job.error_message = f"Ditto/GPU/MediaKit 均失败: {exc}"
job.error_code = "AllBackendsFailed"
job.updated_at = datetime.now(UTC)
db.commit()
logger.error("[ditto_task] 所有兜底均失败: job_id=%s err=%s", job.id, exc)
def _shared_storage():
from packages.shared.storage import get_shared_storage_service
return get_shared_storage_service()
@shared_task(
name="lipsync_ditto_process_async",
bind=True,
max_retries=0,
acks_late=True,
time_limit=600,
soft_time_limit=540,
)
def lipsync_ditto_process_async(self, job_id: str, user_id: str) -> None:
"""异步调用 Ditto 生成口型视频。
Args:
job_id: LipsyncJob ID
user_id: 用户 ID
"""
from packages.application.ditto_service import DittoError, get_ditto_client
db: Session = _get_db_session()
job: Optional[LipsyncJobModel] = None
try:
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
job = db.query(LipsyncJobModel).filter_by(id=job_id, user_id=user_id).first()
if job is None:
logger.error("[ditto_task] job 不存在: job_id=%s", job_id)
return
if job.status != "processing":
logger.warning(
"[ditto_task] job 状态异常(非 processing),跳过: job_id=%s status=%s",
job_id,
job.status,
)
return
audio_url = job.audio_url or ""
script = job.script_text or ""
if not audio_url:
raise DittoError("job.audio_url 为空,无法调用 Ditto", code="InvalidParam")
logger.info(
"[ditto_task] 开始 Ditto 生成: job_id=%s audio=%s script_len=%d",
job_id,
audio_url[:100],
len(script),
)
client = get_ditto_client()
result = client.generate_and_persist(
job_id=job_id,
user_id=user_id,
audio_url=audio_url,
script=script,
# video_url 不传则用默认模板
)
# Ditto 返回的 MP4 自带音频,直接标记完成
job.output_video_url = result.video_url
# 探测时长(用于计费)
duration = _probe_video_duration(result.video_bytes)
if duration <= 0:
# 兜底:按音频时长估算(1秒≈1秒)
try:
from packages.domain.sentence_timings import probe_audio_duration
from packages.shared.url_security import safe_download_bytes
audio_data = safe_download_bytes(
audio_url, allowed_mime_types=("audio/mpeg", "audio/wav", "audio/x-wav"), timeout=30
)
duration = probe_audio_duration(audio_data)
except Exception:
duration = 0.0
job.output_duration = duration
job.status = "completed"
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info(
"[ditto_task] Ditto 完成: job_id=%s url=%s duration=%.2fs rtf=%.2f frames=%d",
job_id,
result.video_url[:100],
duration,
result.rtf,
result.frames,
)
_settle_lip_sync(db, job, duration)
except DittoError as exc:
logger.error("[ditto_task] Ditto 失败,回退: job_id=%s code=%s err=%s", job_id, exc.code, exc)
if job is not None:
try:
db.rollback()
job = db.query(type(job)).filter_by(id=job_id).first() if hasattr(job, "id") else job
# 回退 GPU/MediaKit
_fallback_to_gpu_then_mediakit(db, job)
except Exception as fallback_exc:
logger.exception("[ditto_task] 回退也失败 job_id=%s err=%s", job_id, fallback_exc)
try:
if job:
job.status = "failed"
job.error_message = f"Ditto 失败且回退异常: {exc}; fallback: {fallback_exc}"
job.error_code = "FallbackError"
job.updated_at = datetime.now(UTC)
db.commit()
except Exception:
pass
except Exception as exc:
logger.exception("[ditto_task] 未预期异常: job_id=%s err=%s", job_id, exc)
if job is not None:
try:
db.rollback()
job = db.query(type(job)).filter_by(id=job_id).first()
_fallback_to_gpu_then_mediakit(db, job)
except Exception as fallback_exc:
logger.exception("[ditto_task] 回退也失败 job_id=%s err=%s", job_id, fallback_exc)
try:
if job:
job.status = "failed"
job.error_message = f"Ditto 异常: {exc}"
job.error_code = "DittoAsyncError"
job.updated_at = datetime.now(UTC)
db.commit()
except Exception:
pass
finally:
db.close()
+27 -1
View File
@@ -261,7 +261,33 @@ def tts_synthesize_and_submit(
"[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True
)
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
# 3. 优先走 Ditto(#2076):开关打开且配置完整时,派发 Ditto 异步任务,不再走 MediaKit
ditto_dispatched = False
try:
from packages.config import get_api_settings as _get_settings
_settings = _get_settings()
if _settings.use_ditto_lipsync and _settings.ditto_api_base_url and _settings.ditto_default_video_url:
from app.tasks.lipsync_ditto import lipsync_ditto_process_async
job.status = "processing"
job.mediakit_task_id = "ditto:tts-submitted"
job.updated_at = datetime.now(UTC)
db.commit()
lipsync_ditto_process_async.apply_async(args=(job_id, user_id))
logger.info("[lipsync_tts] TTS 完成,已派发 Ditto 任务: job_id=%s", job_id)
ditto_dispatched = True
except Exception as _ditto_err:
logger.warning("[lipsync_tts] Ditto 派发失败,回退 MediaKit: job_id=%s err=%s", job_id, _ditto_err)
try:
db.rollback()
except Exception:
pass
if ditto_dispatched:
return
# 4. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
audio_url = _sign_media_url(job.audio_url)
video_url = _sign_media_url(job.video_url)
+12
View File
@@ -14,6 +14,7 @@
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"marked": "^12.0.2",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -4502,6 +4503,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmmirror.com/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -25,6 +25,7 @@
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"marked": "^12.0.2",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+4
View File
@@ -83,6 +83,8 @@ export interface ImageProductAnalysis {
label_text?: string
selling_points?: string
image_index?: number
/** v8: 用户端展示用的 markdown 描述(由提示词控制排版) */
summary_markdown?: string
}
export interface ImageAnalysisResult {
@@ -132,6 +134,8 @@ export interface CopyResult {
/** 向后兼容:= voiceover_script */
suggested_copy?: string
title?: string
/** v3 storyboard: 用户端展示用的 markdown 文案(由提示词控制排版) */
copy_display_markdown?: string
/** v1.5 旧字段兼容(老数据降级时可能出现) */
scenes?: Array<{ shot: string; narration: string; duration?: number }>
}
@@ -1973,3 +1973,99 @@
padding-bottom: 6px;
border-bottom: 1px dashed #e5e7eb;
}
/* ─────────── markdown 渲染(提示词控制展示格式) ─────────── */
.vv-recog-md {
padding: 4px 0;
}
.vv-copy-preview {
margin-bottom: 14px;
padding: 12px 14px;
background: linear-gradient(180deg, #faf7ff 0%, #f6f2ff 100%);
border: 1px solid #ece4fb;
border-radius: 10px;
}
.vv-copy-preview-h {
margin: 0 0 8px;
border-bottom: none;
padding-bottom: 0;
}
.vv-md-body {
font-size: 13px;
line-height: 1.7;
color: #374151;
word-break: break-word;
}
.vv-md-body h1,
.vv-md-body h2,
.vv-md-body h3,
.vv-md-body h4 {
margin: 10px 0 6px;
font-weight: 600;
color: #1f2937;
line-height: 1.4;
}
.vv-md-body h1 {
font-size: 18px;
}
.vv-md-body h2 {
font-size: 16px;
}
.vv-md-body h3 {
font-size: 15px;
}
.vv-md-body h4 {
font-size: 14px;
}
.vv-md-body p {
margin: 6px 0;
}
.vv-md-body ul,
.vv-md-body ol {
margin: 6px 0;
padding-left: 20px;
}
.vv-md-body li {
margin: 3px 0;
}
.vv-md-body strong {
color: #111827;
font-weight: 600;
}
.vv-md-body blockquote {
margin: 8px 0;
padding: 4px 12px;
border-left: 3px solid #7c3aed;
background: rgba(124, 58, 237, 0.05);
color: #4b5563;
}
.vv-md-body code {
padding: 1px 5px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
color: #be185d;
}
.vv-md-body a {
color: #7c3aed;
text-decoration: none;
}
.vv-md-body a:hover {
text-decoration: underline;
}
.vv-md-body table {
border-collapse: collapse;
margin: 8px 0;
width: 100%;
}
.vv-md-body th,
.vv-md-body td {
border: 1px solid #e5e7eb;
padding: 6px 10px;
text-align: left;
}
.vv-md-body hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 12px 0;
}
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from "react"
import axios from "axios"
import { marked } from "marked"
import {
PlusOutlined,
CloseOutlined,
@@ -150,6 +151,16 @@ type TabTask = {
audioInst: HTMLAudioElement | null
}
/* ── marked 配置:禁用 mangle/headerIds,输出干净 HTML ── */
marked.setOptions({ gfm: true, breaks: false })
const renderMarkdown = (md: string): string => {
try {
return marked.parse(md ?? "", { async: false }) as string
} catch {
return (md ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;")
}
}
/* ─────────── 常量 ─────────── */
const LANGUAGES = ["中文(普通话)", "粤语", "英语", "日语", "韩语"]
@@ -296,6 +307,8 @@ interface Storyboard {
hard_constraints: string[]
negative_prompts: string[]
voiceover_script: string
/** v3: 用户端展示用 markdown 文案(由提示词控制排版) */
copy_display_markdown: string
}
/** 兼容旧 copy_result(final_copy/title/scenes)→ 新 Storyboard 结构 */
@@ -323,6 +336,7 @@ function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard |
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 || "",
}
}
// 兜底:旧结构转简单分镜
@@ -358,6 +372,7 @@ function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard |
hard_constraints: [],
negative_prompts: [],
voiceover_script: finalCopy,
copy_display_markdown: cr.copy_display_markdown || "",
}
}
@@ -412,6 +427,7 @@ const MOCK_STORYBOARD: Storyboard = {
negative_prompts: ["冷色调", "模糊", "变形", "水印文字", "卡通风格", "空无一人"],
voiceover_script:
"还在为餐桌选不到好桌子发愁?这张北美黑胡桃木餐桌,一家人坐下来吃饭刚刚好。全实木、无贴皮,纹理好看又耐刮。点小黄车,给家里添一张好桌子。",
copy_display_markdown: "",
}
const fmtSize = (bytes: number | undefined) => {
@@ -1217,67 +1233,76 @@ const ViralVideoPage: React.FC = () => {
<CheckCircleFilled style={{ color: "#10b981" }} />
识别描述汇览
</div>
{products.map((p, i) => (
<div key={i} className="vv-recog-item">
<div className="vv-recog-line">
<span className="vv-recog-k">图片{i + 1}:</span>
<span>
{p.name || "未识别"}
{p.spec && <span className="vv-recog-meta">({p.spec})</span>}
{p.brand && <span className="vv-recog-meta"> · {p.brand}</span>}
{p.category && <span className="vv-recog-meta"> · {p.category}</span>}
</span>
{products.map((p, i) =>
p.summary_markdown ? (
<div key={i} className="vv-recog-item vv-recog-md">
<div
className="vv-md-body"
dangerouslySetInnerHTML={{ __html: renderMarkdown(p.summary_markdown) }}
/>
</div>
{featureText(p.key_features ?? p.features) && (
) : (
<div key={i} className="vv-recog-item">
<div className="vv-recog-line">
<span className="vv-recog-k">核心特征:</span>
<span className="vv-recog-v">{featureText(p.key_features ?? p.features)}</span>
<span className="vv-recog-k">图片{i + 1}:</span>
<span>
{p.name || "未识别"}
{p.spec && <span className="vv-recog-meta">({p.spec})</span>}
{p.brand && <span className="vv-recog-meta"> · {p.brand}</span>}
{p.category && <span className="vv-recog-meta"> · {p.category}</span>}
</span>
</div>
)}
{p.colors && p.colors.length > 0 && (
<div className="vv-recog-line">
<span className="vv-recog-k">主色调:</span>
<span className="vv-recog-v">{p.colors.join(" / ")}</span>
</div>
)}
{p.material_or_texture && (
<div className="vv-recog-line">
<span className="vv-recog-k">材质/纹理:</span>
<span className="vv-recog-v">{p.material_or_texture}</span>
</div>
)}
{p.visual_style && (
<div className="vv-recog-line">
<span className="vv-recog-k">视觉风格:</span>
<span className="vv-recog-v">{p.visual_style}</span>
</div>
)}
{p.scene && (
<div className="vv-recog-line">
<span className="vv-recog-k">场景:</span>
<span className="vv-recog-v">{p.scene}</span>
</div>
)}
{p.target_audience_hint && (
<div className="vv-recog-line">
<span className="vv-recog-k">目标人群:</span>
<span className="vv-recog-v">{p.target_audience_hint}</span>
</div>
)}
{(p.text_on_image || p.label_text) && (
<div className="vv-recog-line">
<span className="vv-recog-k">包装文字:</span>
<span className="vv-recog-v">{p.text_on_image || p.label_text}</span>
</div>
)}
{p.selling_points && (
<div className="vv-recog-line">
<span className="vv-recog-k">卖点:</span>
<span className="vv-recog-v">{p.selling_points}</span>
</div>
)}
</div>
))}
{featureText(p.key_features ?? p.features) && (
<div className="vv-recog-line">
<span className="vv-recog-k">核心特征:</span>
<span className="vv-recog-v">{featureText(p.key_features ?? p.features)}</span>
</div>
)}
{p.colors && p.colors.length > 0 && (
<div className="vv-recog-line">
<span className="vv-recog-k">主色调:</span>
<span className="vv-recog-v">{p.colors.join(" / ")}</span>
</div>
)}
{p.material_or_texture && (
<div className="vv-recog-line">
<span className="vv-recog-k">材质/纹理:</span>
<span className="vv-recog-v">{p.material_or_texture}</span>
</div>
)}
{p.visual_style && (
<div className="vv-recog-line">
<span className="vv-recog-k">视觉风格:</span>
<span className="vv-recog-v">{p.visual_style}</span>
</div>
)}
{p.scene && (
<div className="vv-recog-line">
<span className="vv-recog-k">场景:</span>
<span className="vv-recog-v">{p.scene}</span>
</div>
)}
{p.target_audience_hint && (
<div className="vv-recog-line">
<span className="vv-recog-k">目标人群:</span>
<span className="vv-recog-v">{p.target_audience_hint}</span>
</div>
)}
{(p.text_on_image || p.label_text) && (
<div className="vv-recog-line">
<span className="vv-recog-k">包装文字:</span>
<span className="vv-recog-v">{p.text_on_image || p.label_text}</span>
</div>
)}
{p.selling_points && (
<div className="vv-recog-line">
<span className="vv-recog-k">卖点:</span>
<span className="vv-recog-v">{p.selling_points}</span>
</div>
)}
</div>
),
)}
</div>
)
}
@@ -1416,6 +1441,19 @@ const ViralVideoPage: React.FC = () => {
return (
<div className="vv-copy-box vv-storyboard">
<div className="vv-sb-doc">
{/* 文案预览(提示词控制排版,只读;编辑在下方分镜字段中进行) */}
{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">
+3
View File
@@ -53,6 +53,9 @@ celery_app.conf.imports = (
# #1998 GPU MuseTalk 异步推理:wait_for_result→签名 URL→回写 lipsync_jobs
# 必须在 Worker 侧注册,否则 apply_async 消息无人消费,job 永远卡在 processing
"app.tasks.lipsync_gpu",
# #2076 Ditto 蚂蚁数字人异步推理:同步 HTTP 调用 Ditto → MP4 流转存 OSS → 回写 lipsync_jobs
# 必须在 Worker 侧注册;失败回退 GPU MuseTalk → MediaKit
"app.tasks.lipsync_ditto",
)
# Celery Beat 定时任务调度
+23 -10
View File
@@ -948,6 +948,10 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
base["final_copy"] = joined
base["suggested_copy"] = joined
base["title"] = base["overview"]["theme"]
# 提取 copy_display_markdown(用户端展示格式)
copy_display_md = xp.text_of(raw, "copy_display_markdown")
if copy_display_md:
base["copy_display_markdown"] = copy_display_md
return base
@@ -2072,18 +2076,27 @@ def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
try:
review_result = _step_review(job, copy_result)
if not review_result.get("passed", True):
_emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...")
# #2040: Reviewer 已在 _step_review 内完成 1 次自动重写
rewritten = review_result.get("rewritten_copy")
if isinstance(rewritten, dict) and rewritten:
copy_result = rewritten
else:
# #2218: 审核重写失败不再从意图解析重跑,直接报错让用户重新生成文案
logger.error(
"[爆款视频][阶段3] 合规审核未通过且自动重写失败 job_id=%s,终止渲染",
# #2233: 如果issues为空但passed=False,说明是LLM超时/异常导致的误判,降级放行
_issues = review_result.get("issues") or []
if not _issues:
logger.warning(
"[爆款视频][阶段3] 审核未通过但无具体问题(可能LLM超时),降级放行 job_id=%s",
job_id,
)
raise ValueError("文案合规审核未通过,请修改文案后重试或重新生成文案")
review_result["passed"] = True
else:
_emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...")
# #2040: Reviewer 已在 _step_review 内完成 1 次自动重写
rewritten = review_result.get("rewritten_copy")
if isinstance(rewritten, dict) and rewritten:
copy_result = rewritten
else:
# #2218: 审核重写失败不再从意图解析重跑,直接报错让用户重新生成文案
logger.error(
"[爆款视频][阶段3] 合规审核未通过且自动重写失败 job_id=%s,终止渲染",
job_id,
)
raise ValueError("文案合规审核未通过,请修改文案后重试或重新生成文案")
job.copy_result = copy_result
job.generated_copy_text = copy_result.get("voiceover_script", "") or ""
_save_job(repo, job, session)
@@ -22,6 +22,90 @@ _AGE_PREFIX = {"青年": "年轻", "中年": "中年", "老年": "老年"}
_GENDER_WORD = {"男": "男性", "女": "女性"}
def _build_summary_markdown(product: dict) -> str:
"""根据结构化字段生成 summary_markdown 兜底(VLM未返回时调用)。"""
p = product or {}
name = p.get("name") or "未识别"
brand = p.get("brand") or ""
category = p.get("category") or ""
appearance = p.get("appearance") or ""
packaging = p.get("packaging") or ""
scene = p.get("scene") or ""
mood = p.get("mood") or ""
key_features = p.get("key_features") or []
text_on_package = p.get("text_on_package") or []
store_type = p.get("store_type") or ""
brand_signage = p.get("brand_signage") or ""
visual_elements = p.get("visual_elements") or []
atmosphere = p.get("atmosphere") or ""
outfit_style = p.get("outfit_style") or ""
upper_wear = p.get("upper_wear") or ""
lower_wear = p.get("lower_wear") or ""
expression = p.get("expression") or ""
has_person = p.get("has_person", False)
person_count = p.get("person_count") or 0
lines = []
# 门店场景
if store_type or "门店" in str(category) or "店铺" in str(category):
lines.append(f"### {brand_signage or store_type or '门店'}")
if store_type:
lines.append(f"**类型**:{store_type}")
if brand_signage:
lines.append(f"**品牌标识**:{brand_signage}")
if atmosphere:
lines.append(f"**氛围**:{atmosphere}")
if visual_elements:
lines.append("**陈列亮点**")
for e in visual_elements[:5]:
lines.append(f"- {e}")
return "\n".join(lines)
# 人物
if has_person or person_count or outfit_style:
lines.append("### 人物描述")
if person_count:
lines.append(f"**人数**:{person_count}人")
if outfit_style:
lines.append(f"**风格**:{outfit_style}")
wear_parts = []
if upper_wear:
wear_parts.append(f"上装:{upper_wear}")
if lower_wear:
wear_parts.append(f"下装:{lower_wear}")
if wear_parts:
lines.append("**穿搭**")
for w in wear_parts:
lines.append(f"- {w}")
if expression:
lines.append(f"**气质**:{expression}")
return "\n".join(lines)
# 商品(默认)
lines.append(f"### {name}")
if brand and brand != "无法判断":
lines.append(f"**品牌**:{brand}")
if category and category != "非产品图" and category != "无法判断":
lines.append(f"**类目**:{category}")
if key_features:
lines.append("**核心特征**")
for kf in key_features[:5]:
lines.append(f"- {kf}")
if appearance:
lines.append(f"**外观**:{appearance[:100]}")
if packaging and packaging != "无法判断":
lines.append(f"**包装**:{packaging}")
if text_on_package:
lines.append(f"**文字信息**:{'、'.join(text_on_package[:3])}")
if scene and scene != "通用":
lines.append(f"**场景**:{scene}")
if mood:
lines.append(f"**氛围**:{mood}")
return "\n".join(lines) if lines else f"### {name}\n暂无详细描述"
def _person_subject(gender: str, age: str) -> str:
gw = _GENDER_WORD.get(gender, "")
if age == "儿童":
@@ -384,7 +468,11 @@ def assemble_result(idx: int, fast_json: dict | None, ocr_texts: list[str]) -> d
result = _assemble_v4(idx, fj, ocr_texts)
else:
result = _assemble_old(idx, fj, ocr_texts)
return _apply_partial_fallback(result, fj)
result = _apply_partial_fallback(result, fj)
# summary_markdown: 优先用VLM输出,否则兜底生成
if "summary_markdown" not in result or not result.get("summary_markdown"):
result["summary_markdown"] = _build_summary_markdown(result)
return result
def _apply_partial_fallback(result: dict[str, Any], fj: dict) -> dict[str, Any]:
@@ -681,8 +769,13 @@ def _assemble_v4(idx: int, fj: dict, ocr_texts: list[str]) -> dict[str, Any]:
brand = "无法判断"
else:
brand = brand_raw
# name兜底:store_type为空时用brand
name = store_type if store_type != "店铺" else (brand if brand != "无法判断" else store_type)
# name兜底:更保守的策略
# - store_type有具体值(非"店铺")时直接用store_type
# - store_type为默认"店铺"时,用"门店门头"而非brand(避免与brand字段重复)
if store_type and store_type != "店铺":
name = store_type
else:
name = "门店门头"
category = "门店场景"
# appearance: store_layout + furnishings + 陈设色调
appearance_parts = []
+269
View File
@@ -0,0 +1,269 @@
"""蚂蚁 Ditto 数字人口型 API 客户端 — #2076.
封装 Ditto FastAPI(部署在 5060Ti GPU 节点,Tailscale 内网可达):
- GET /health 健康检查
- POST /generate 生成口型视频(同步返回 MP4 流)
关键特性:
- 入参:video_url(人物模板视频 URL) + audio_url(TTS 音频 URL) + script(文案原文)
- 出参:直接返回 video/mp4 字节流(自带音频,无需二次混流)
- 429 时指数退避重试(最多 ditto_max_retries 次)
- 500/超时视为失败
- 输出 MP4 字节流转存到自家 OSS,返回公网 URL
注意:
- 保留 MuseTalk/GPU 路径不变;本服务作为更高优先级的第三条口型路径
- 不传 emotion/表情精细控制,使用默认 emo_global=4(中性)+ use_script_emo=true(关键词驱动表情)
- Ditto 输出自带音视频,不需要 GFPGAN 超分,不需要 ffmpeg 音视频混流
"""
from __future__ import annotations
import io
import logging
import time
from dataclasses import dataclass
from typing import Optional
import httpx
from packages.config import get_api_settings
logger = logging.getLogger(__name__)
class DittoError(Exception):
"""Ditto API 调用失败."""
def __init__(self, message: str, code: str = "DittoError", status_code: int = 0):
self.code = code
self.status_code = status_code
super().__init__(message)
@dataclass
class DittoResult:
"""Ditto 生成结果."""
video_bytes: bytes
video_url: str = "" # 转存 OSS 后填充
elapsed_seconds: float = 0.0
rtf: float = 0.0 # 实时率(响应头 X-RTF)
frames: int = 0 # 帧数(响应头 X-Frames)
class DittoClient:
"""蚂蚁 Ditto 数字人口型 API 客户端."""
def __init__(
self,
base_url: Optional[str] = None,
default_video_url: Optional[str] = None,
max_retries: Optional[int] = None,
timeout: Optional[int] = None,
):
s = get_api_settings()
self.base_url = (base_url or s.ditto_api_base_url or "").rstrip("/")
self.default_video_url = default_video_url or s.ditto_default_video_url or ""
self.max_retries = int(max_retries if max_retries is not None else s.ditto_max_retries)
self.timeout = int(timeout if timeout is not None else s.ditto_request_timeout)
@property
def is_configured(self) -> bool:
"""配置是否完整(base_url + 默认模板视频都有值)."""
return bool(self.base_url) and bool(self.default_video_url)
def health(self) -> bool:
"""健康检查;成功返回 True,失败返回 False(不抛异常)."""
if not self.base_url:
return False
url = f"{self.base_url}/health"
try:
with httpx.Client(timeout=5.0) as client:
resp = client.get(url)
ok = resp.status_code == 200
if ok:
logger.info("[ditto] health check OK: %s", url)
else:
logger.warning("[ditto] health check status=%d: %s", resp.status_code, url)
return ok
except Exception as exc:
logger.warning("[ditto] health check failed: %s", exc)
return False
def generate(
self,
*,
audio_url: str,
script: str,
video_url: Optional[str] = None,
emo_global: int = 4,
use_script_emo: bool = True,
blend_frames: int = 6,
) -> DittoResult:
"""调用 Ditto /generate 接口,返回 MP4 字节流结果.
Raises DittoError on failure.
"""
if not self.base_url:
raise DittoError("DITTO_API_BASE_URL 未配置", code="ConfigMissing")
driver_url = video_url or self.default_video_url
if not driver_url:
raise DittoError("Ditto 人物模板视频 URL 未配置", code="ConfigMissing")
if not audio_url:
raise DittoError("audio_url 不能为空", code="InvalidParam")
if not script:
script = " "
payload = {
"video_url": driver_url,
"audio_url": audio_url,
"script": script,
"emo_global": emo_global,
"use_script_emo": use_script_emo,
"blend_frames": blend_frames,
}
url = f"{self.base_url}/generate"
last_exc: Optional[Exception] = None
for attempt in range(self.max_retries + 1):
try:
start = time.monotonic()
with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
resp = client.post(url, json=payload)
elapsed = time.monotonic() - start
if resp.status_code == 429:
wait = min(2**attempt, 30)
logger.warning(
"[ditto] GPU 繁忙 (429),%ds 后重试 (%d/%d)",
wait,
attempt + 1,
self.max_retries,
)
if attempt >= self.max_retries:
raise DittoError(
f"Ditto GPU 繁忙,重试 {self.max_retries} 次仍失败",
code="BusyRetriesExhausted",
status_code=429,
)
time.sleep(wait)
continue
if resp.status_code != 200:
_text = (resp.text or "")[:300]
logger.error(
"[ditto] generate 失败 status=%d attempt=%d body=%s",
resp.status_code,
attempt + 1,
_text,
)
if resp.status_code >= 500 and attempt < self.max_retries:
time.sleep(min(2**attempt, 15))
continue
raise DittoError(
f"Ditto 返回 {resp.status_code}: {_text}",
code="DittoAPIError",
status_code=resp.status_code,
)
video_bytes = resp.content
if not video_bytes or len(video_bytes) < 1024:
raise DittoError(
f"Ditto 返回内容异常(size={len(video_bytes) if video_bytes else 0})",
code="EmptyResponse",
)
try:
rtf = float(resp.headers.get("X-RTF", "0") or 0)
except ValueError:
rtf = 0.0
try:
frames = int(resp.headers.get("X-Frames", "0") or 0)
except ValueError:
frames = 0
try:
x_time = float(resp.headers.get("X-Time", "0") or 0)
if x_time > 0:
elapsed = x_time
except ValueError:
pass
logger.info(
"[ditto] generate 成功 size=%d rtf=%.2f frames=%d elapsed=%.1fs attempt=%d",
len(video_bytes),
rtf,
frames,
elapsed,
attempt + 1,
)
return DittoResult(
video_bytes=video_bytes,
elapsed_seconds=elapsed,
rtf=rtf,
frames=frames,
)
except DittoError:
raise
except httpx.TimeoutException as exc:
last_exc = exc
logger.warning("[ditto] 请求超时 attempt=%d err=%s", attempt + 1, exc)
if attempt < self.max_retries:
time.sleep(min(2**attempt, 15))
continue
raise DittoError(
f"Ditto 请求超时({self.timeout}s),重试耗尽",
code="Timeout",
) from exc
except Exception as exc:
last_exc = exc
logger.warning("[ditto] 请求异常 attempt=%d err=%s", attempt + 1, exc)
if attempt < self.max_retries:
time.sleep(min(2**attempt, 10))
continue
raise DittoError(f"Ditto 调用异常: {exc}", code="NetworkError") from exc
raise DittoError("Ditto 未知错误", code="Unknown") from last_exc
def generate_and_persist(
self,
*,
job_id: str,
user_id: str,
audio_url: str,
script: str,
video_url: Optional[str] = None,
) -> DittoResult:
"""调用 generate 并把 MP4 转存到自家 OSS,返回带 video_url 的结果."""
result = self.generate(audio_url=audio_url, script=script, video_url=video_url)
try:
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
storage_key = f"ditto-output/{user_id}/{job_id}.mp4"
public_url = storage.upload_file(
io.BytesIO(result.video_bytes),
storage_key,
content_type="video/mp4",
)
result.video_url = public_url
logger.info(
"[ditto] 转存 OSS 完成 job=%s key=%s",
job_id,
storage_key,
)
except Exception as exc:
logger.error("[ditto] 转存 OSS 失败 job=%s err=%s", job_id, exc, exc_info=True)
raise DittoError(f"Ditto 结果转存 OSS 失败: {exc}", code="StorageError") from exc
return result
_ditto_client_singleton: Optional[DittoClient] = None
def get_ditto_client() -> DittoClient:
"""获取 DittoClient 单例(简易工厂,便于单测 mock)."""
global _ditto_client_singleton
if _ditto_client_singleton is None:
_ditto_client_singleton = DittoClient()
return _ditto_client_singleton
+19 -1
View File
@@ -53,6 +53,9 @@ _LOCATIONS = ["title", "hook", "body_points", "cta", "script_segments"]
class Reviewer:
# markdown展示字段不参与合规审核(避免格式字符误判)
_MARKDOWN_FIELDS = {"summary_markdown", "copy_display_markdown"}
def __init__(self, client=None):
if client is None:
try:
@@ -70,8 +73,9 @@ class Reviewer:
local = self._rule_check(fusion, intent, fusion_level)
llm_result = self._llm_review(fusion, intent, fusion_level)
if llm_result is None:
# LLM审核失败(超时/网络错误等),降级放行,不阻断渲染
return ReviewResult(
passed=not local,
passed=True,
issues=local,
rewrite_suggestions=[],
raw="",
@@ -86,6 +90,17 @@ class Reviewer:
)
def _llm_review(self, fusion: FusionResult, intent: IntentResult, fusion_level: str) -> Optional[ReviewResult]:
try:
return self._llm_review_inner(fusion, intent, fusion_level)
except Exception as e:
import logging
logging.getLogger(__name__).warning("[Reviewer] LLM审核调用异常,降级放行: %s", e)
return None
def _llm_review_inner(
self, fusion: FusionResult, intent: IntentResult, fusion_level: str
) -> Optional[ReviewResult]:
template = get_template("review")
system = render_system_prompt(template)
user = render_user_prompt(
@@ -303,10 +318,13 @@ class Reviewer:
@staticmethod
def _fusion_text(fusion: FusionResult) -> str:
_MARKDOWN_FIELDS = {"summary_markdown", "copy_display_markdown"}
parts = [fusion.title, fusion.hook]
parts += [p.text for p in fusion.body_points]
parts += [s.text for s in fusion.script_segments]
parts.append(fusion.cta)
# 过滤掉markdown展示字段,避免格式字符被误判
parts = [p for p in parts if not any(mk in p for mk in _MARKDOWN_FIELDS)]
return "\n".join(p for p in parts if p)
@staticmethod
+29
View File
@@ -173,6 +173,35 @@ class SharedSettings(BaseSettings):
# 判断 Worker 可用的心跳新鲜度窗口(秒)—— last_heartbeat_at 在窗口内视为在线
gpu_worker_stale_seconds: int = 300
# ── Ditto 蚂蚁数字人口型 API(#2076)─────────────────────────────────
# 是否优先使用 Ditto(蚂蚁数字人,替代 MuseTalk)。开关开启且 base_url 配置
# 非空时,对口型任务优先走 Ditto;失败后回退 MuseTalk/MediaKit。
use_ditto_lipsync: bool = Field(
default=False,
validation_alias=AliasChoices("USE_DITTO_LIPSYNC", "use_ditto_lipsync"),
)
# Ditto FastAPI 内网地址(Tailscale),如 http://100.x.x.x:8000
ditto_api_base_url: str = Field(
default="",
validation_alias=AliasChoices("DITTO_API_BASE_URL", "ditto_api_base_url"),
)
# 默认人物模板视频 URL(正面 5-10 秒循环、光线均匀、半身)。Ditto 模式下忽略
# 用户上传的驱动视频/图片,统一用该模板;后续可扩展为多模板让用户选择。
ditto_default_video_url: str = Field(
default="",
validation_alias=AliasChoices("DITTO_DEFAULT_VIDEO_URL", "ditto_default_video_url"),
)
# 429 GPU 繁忙时指数退避最大重试次数
ditto_max_retries: int = Field(
default=3,
validation_alias=AliasChoices("DITTO_MAX_RETRIES", "ditto_max_retries"),
)
# Ditto 单次请求超时(秒):数字人半身视频推理通常 30-120s
ditto_request_timeout: int = Field(
default=300,
validation_alias=AliasChoices("DITTO_REQUEST_TIMEOUT", "ditto_request_timeout"),
)
# ── P4000 NVENC 硬件编码 ────────────────────────────────────────────
# GPU 编码总开关;关闭或 endpoint 为空时始终走本机 CPU libx264
enable_gpu_encode: bool = Field(
+2 -2
View File
@@ -337,13 +337,13 @@ class DoubaoClient:
self.last_finish_reason = finish_reason
_elapsed = time.time() - _t0
logger.info(
"[doubao] chat_completion 完成 model=%s tokens_in=%d tokens_out=%d elapsed=%.1fs attempt=%d timeout=%d",
"[doubao] chat_completion 完成 model=%s tokens_in=%d tokens_out=%d elapsed=%.1fs attempt=%d timeout=%s",
payload.get("model"),
data.get("usage", {}).get("prompt_tokens", 0),
data.get("usage", {}).get("completion_tokens", 0),
_elapsed,
attempt + 1,
_req_timeout,
getattr(_req_timeout, "read", _req_timeout),
)
return content.strip()
except Exception as e:
+2
View File
@@ -51,6 +51,8 @@ task_routes = {
"ai_avatar_render.execute": {"queue": QUEUE_GENERATION},
# GPU MuseTalk 口型同步(用户等成片,链路子任务全部走 generation 避免跨队列阻塞)
"lipsync_gpu_process_async": {"queue": QUEUE_GENERATION},
# #2076 Ditto 蚂蚁数字人口型同步(走 generation 队列,避免跨队列阻塞)
"lipsync_ditto_process_async": {"queue": QUEUE_GENERATION},
"lipsync_tts.synthesize_and_submit": {"queue": QUEUE_GENERATION},
"lipsync_tts.poll_mediakit_status": {"queue": QUEUE_GENERATION},
"lipsync_tts.persist_output_video": {"queue": QUEUE_GENERATION},
+197
View File
@@ -0,0 +1,197 @@
"""Ditto 蚂蚁数字人客户端单元测试 — #2076."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import httpx
import pytest
from packages.application.ditto_service import DittoClient, DittoError, DittoResult
class _FakeResponse:
def __init__(self, status_code=200, content=b"\x00\x01" * 1000, headers=None, text=""):
self.status_code = status_code
self.content = content
self.headers = headers or {}
self.text = text
def _make_client(base_url="http://ditto:8000", default_video_url="http://oss/tpl.mp4", max_retries=2, timeout=60):
with patch("packages.application.ditto_service.get_api_settings") as mock_settings:
s = MagicMock()
s.ditto_api_base_url = base_url
s.ditto_default_video_url = default_video_url
s.ditto_max_retries = max_retries
s.ditto_request_timeout = timeout
mock_settings.return_value = s
return DittoClient()
def test_is_configured_true():
c = _make_client()
assert c.is_configured is True
def test_is_configured_false_without_base():
c = _make_client(base_url="")
assert c.is_configured is False
def test_is_configured_false_without_template():
c = _make_client(default_video_url="")
assert c.is_configured is False
def test_health_ok():
c = _make_client()
with patch("httpx.Client") as mock_cls:
client = MagicMock()
client.get.return_value = _FakeResponse(200)
mock_cls.return_value.__enter__.return_value = client
assert c.health() is True
client.get.assert_called_once()
def test_health_fail_status():
c = _make_client()
with patch("httpx.Client") as mock_cls:
client = MagicMock()
client.get.return_value = _FakeResponse(500)
mock_cls.return_value.__enter__.return_value = client
assert c.health() is False
def test_health_network_error():
c = _make_client()
with patch("httpx.Client") as mock_cls:
client = MagicMock()
client.get.side_effect = httpx.ConnectError("fail")
mock_cls.return_value.__enter__.return_value = client
assert c.health() is False
def test_generate_missing_base():
c = _make_client(base_url="")
with pytest.raises(DittoError, match="DITTO_API_BASE_URL"):
c.generate(audio_url="http://x/a.mp3", script="你好")
def test_generate_missing_audio():
c = _make_client()
with pytest.raises(DittoError, match="audio_url"):
c.generate(audio_url="", script="你好")
def test_generate_success_with_headers():
c = _make_client(max_retries=0)
fake_resp = _FakeResponse(
status_code=200,
content=b"\x00" * 99999,
headers={"X-RTF": "0.35", "X-Frames": "125", "X-Time": "12.5"},
)
with patch("httpx.Client") as mock_cls, patch("time.monotonic", side_effect=[0, 1]):
client = MagicMock()
client.post.return_value = fake_resp
mock_cls.return_value.__enter__.return_value = client
result = c.generate(audio_url="http://x/a.mp3", script="你好")
assert isinstance(result, DittoResult)
assert len(result.video_bytes) == 99999
assert result.rtf == 0.35
assert result.frames == 125
assert result.elapsed_seconds == 12.5
def test_generate_uses_default_template_when_video_url_empty():
c = _make_client(max_retries=0)
fake_resp = _FakeResponse(200, b"1" * 99999)
with patch("httpx.Client") as mock_cls:
client = MagicMock()
client.post.return_value = fake_resp
mock_cls.return_value.__enter__.return_value = client
c.generate(audio_url="http://x/a.mp3", script="你好")
call_kwargs = client.post.call_args
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
assert payload["video_url"] == "http://oss/tpl.mp4"
assert payload["audio_url"] == "http://x/a.mp3"
assert payload["script"] == "你好"
assert payload["emo_global"] == 4
assert payload["use_script_emo"] is True
def test_generate_retries_on_429_then_success():
c = _make_client(max_retries=2)
busy = _FakeResponse(429, b"", text="busy")
ok = _FakeResponse(200, b"v" * 99999)
with patch("httpx.Client") as mock_cls, patch("time.sleep") as mock_sleep:
client = MagicMock()
client.post.side_effect = [busy, ok]
mock_cls.return_value.__enter__.return_value = client
result = c.generate(audio_url="http://x/a.mp3", script="你好")
assert len(result.video_bytes) == 99999
assert mock_sleep.called
assert client.post.call_count == 2
def test_generate_429_exhausted():
c = _make_client(max_retries=1)
with patch("httpx.Client") as mock_cls, patch("time.sleep"):
client = MagicMock()
client.post.return_value = _FakeResponse(429, b"", text="busy")
mock_cls.return_value.__enter__.return_value = client
with pytest.raises(DittoError, match="重试"):
c.generate(audio_url="http://x/a.mp3", script="你好")
def test_generate_400_no_retry():
c = _make_client(max_retries=2)
with patch("httpx.Client") as mock_cls:
client = MagicMock()
client.post.return_value = _FakeResponse(400, b"", text="bad request")
mock_cls.return_value.__enter__.return_value = client
with pytest.raises(DittoError, match="Ditto 返回 400"):
c.generate(audio_url="http://x/a.mp3", script="你好")
assert client.post.call_count == 1 # 400 不重试
def test_generate_small_response_raises():
c = _make_client(max_retries=0)
with patch("httpx.Client") as mock_cls:
client = MagicMock()
client.post.return_value = _FakeResponse(200, b"xx")
mock_cls.return_value.__enter__.return_value = client
with pytest.raises(DittoError) as exc_info:
c.generate(audio_url="http://x/a.mp3", script="你好")
assert exc_info.value.code == "EmptyResponse"
def test_generate_and_persist_uploads_to_storage():
c = _make_client(max_retries=0)
fake_resp = _FakeResponse(200, b"v" * 99999)
fake_storage = MagicMock()
fake_storage.upload_file.return_value = "http://oss/ditto/x.mp4"
with (
patch("httpx.Client") as mock_cls,
patch("packages.shared.storage.get_shared_storage_service", return_value=fake_storage),
):
client = MagicMock()
client.post.return_value = fake_resp
mock_cls.return_value.__enter__.return_value = client
result = c.generate_and_persist(job_id="j1", user_id="u1", audio_url="http://x/a.mp3", script="hi")
assert result.video_url == "http://oss/ditto/x.mp4"
fake_storage.upload_file.assert_called_once()
call_args = fake_storage.upload_file.call_args
assert call_args.args[1].startswith("ditto-output/u1/j1")
def test_empty_script_replaced_with_space():
c = _make_client(max_retries=0)
fake_resp = _FakeResponse(200, b"v" * 99999)
with patch("httpx.Client") as mock_cls:
client = MagicMock()
client.post.return_value = fake_resp
mock_cls.return_value.__enter__.return_value = client
c.generate(audio_url="http://x/a.mp3", script="")
payload = client.post.call_args.kwargs["json"]
assert payload["script"] == " "