Compare commits

..

9 Commits

Author SHA1 Message Date
CI Bot 1a4f475fbf 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 if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 45s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m4s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m9s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m41s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m55s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 23m32s
CI/CD Pipeline / Production Browser E2E (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 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 58s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 88h49m41s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 89h12m30s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 89h12m43s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 88h49m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 89h12m9s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 89h12m14s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 89h12m15s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 89h12m21s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 89h12m21s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 88h49m23s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 89h12m8s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 88h49m23s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 88h49m19s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 89h12m8s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 89h12m58s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 89h47m32s
2026-09-12 14:10:26 +00:00
xiaoxia 2fa6de29bc fix(lipsync): 修复Celery事务竞态导致job永远卡在tts_processing(P0)
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 / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m44s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m55s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m15s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 10m28s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 89h23m49s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 89h23m53s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 89h23m55s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 89h23m33s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 89h23m27s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 89h23m31s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 89h23m33s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 89h23m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 89h23m28s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 89h23m31s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 89h24m9s
根因:create_job() 先 apply_async() 发送 Celery 任务,再 db.commit() 提交事务。
worker 是独立进程+独立DB连接,任务<4ms就被消费,但此时 API 事务还未 commit,
worker 查询 job 返回 None → 静默 return 不重试 → job 永远卡在 tts_processing。

修复:
1. API侧(lipsync_service.py):先 db.commit()+db.refresh(job),再 apply_async() 发任务;
   投递失败/MediaKit提交失败分支也各自 commit,确保状态及时落库。
2. Worker侧(lipsync_tts.py):job not found 时改用 self.retry() 递增重试3次
   (1s/3s/7s退避),作为竞态场景的第二道防线;
   增加 autoretry_for=(OSError, ConnectionError) 自动重试网络抖动;
   max_retries 从2调整到5。

子agent在staging实锤:受影响job共5个,手工重投递后全部3秒内完成TTS+提交MediaKit,
证实TTS本身只需要2秒,卡顿完全是因为竞态。

单测:新增 TestCreateJobCommitOrder 验证 commit 在 apply_async 之前。
2026-09-12 21:50:02 +08:00
xiaoxia 831075a9c0 test(ai-avatar): 补充B-roll/标题单测,修正断言
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 / PR Build Web Image (pull_request) Successful in 1m29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m31s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m49s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
AI Code Review / AI Code Review (pull_request) Successful in 6m28s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m5s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 7m13s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m38s
CI/CD Pipeline / Validate - Security (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 / Staging API Integration Tests (pull_request) Failing after 89h43m16s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 89h43m34s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 89h43m13s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 89h43m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 89h43m6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 89h42m53s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 89h43m12s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 89h43m12s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 89h43m14s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 89h42m54s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 89h43m14s
2026-09-12 21:39:00 +08:00
xiaoxia a83b53ae58 fix(ai-avatar): B-roll时间戳、标题粗体重影、TTS卡死防护
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 / Frontend Lint (pull_request) Successful in 1m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 3m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m59s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m11s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 7m14s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m40s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 25m22s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 89h48m47s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 90h13m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 90h14m1s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 90h14m13s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 89h48m28s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 90h13m45s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 90h13m45s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 90h13m46s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 90h13m52s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 90h13m52s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 89h48m25s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 89h48m28s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 89h48m28s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 90h13m31s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 90h13m34s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 90h13m53s
1. B-roll时间戳全0修复:
   - sentences.ts 新增 sentenceTimings 参数优先使用后端精确时间戳,
     降级才按字数比例估算
   - 修正参数顺序,与 ModalBRollEditor 现有调用 (scriptText, timings, duration) 对齐
   - 前端 AiAvatarPage 已通过 props 传 sentenceTimings(旧版已传但因顺序错位被忽略)

2. 标题粗体重影修复:
   - 之前 bold=true 时使用 borderw=3 + font_color 同色描边模拟粗体,
     会在小字号/竖屏视频上造成字形边缘偏移,视觉上文字像被打印了两次
     (用户截图中的'曝光曝光…'重影)
   - 改为黑色细描边(borderw=2, 黑色),既保留清晰加粗效果又不重影
   - 用户显式开启 stroke 时仍按用户配置走
   - 新增 _resolve_font_path(bold=True) 预留粗体字体查找能力(当前镜像
     无独立 Bold 字体文件,沿用 VF 常规字重)

3. TTS 任务防卡死:
   - 给 tts_synthesize_and_submit 加 soft_time_limit=180s / time_limit=200s,
     避免因网络/上游问题导致 Celery 任务永久挂起(用户之前卡10+分钟
     tts_processing 不失败)
   - 任务开头加 INFO 日志(job_id/voice_id/text_len),方便排查 worker
     是否真的收到任务

相关:staging 用户反馈 5 问题中的 1/2/3 项(B-roll时间、标题重影、对口型卡死)
2026-09-12 21:07:34 +08:00
frontend-dev e250132ace perf(ai-avatar): 对口型加速+封面流程重构 (#1872)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m51s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Style (push) Successful in 2m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m39s
CI/CD Pipeline / Validate - Security (push) Successful in 4m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m5s
CI/CD Pipeline / Unit Tests (push) Failing after 7m43s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Failing after 92h5m54s
CI/CD Pipeline / Build Production API Image (push) Failing after 92h5m55s
CI/CD Pipeline / PR Build Web Image (push) Failing after 92h13m43s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 92h13m22s
CI/CD Pipeline / PR Build API Image (push) Failing after 92h13m23s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 92h12m19s
CI/CD Pipeline / Deploy Production (push) Failing after 92h5m29s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 92h12m19s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 92h13m24s
CI/CD Pipeline / Canary Release to Production (push) Failing after 92h5m29s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 92h5m33s
CI/CD Pipeline / Build Production Web Image (push) Failing after 92h5m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 92h13m17s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 92h47m35s
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-12 19:10:03 +08:00
frontend-dev 774dd27844 fix(ai-avatar): 修复B-roll文案时长全为0.0s的问题 (#1871)
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 4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Integration Tests (push) Successful in 3m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m28s
CI/CD Pipeline / Validate - Style (push) Successful in 3m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m49s
CI/CD Pipeline / Validate - Security (push) Successful in 8m11s
CI/CD Pipeline / Unit Tests (push) Successful in 8m48s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 96h13m57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 96h22m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 96h22m49s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 96h22m28s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 96h21m58s
CI/CD Pipeline / CI Gate (push) Failing after 96h13m40s
CI/CD Pipeline / Build Production Web Image (push) Failing after 96h13m40s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 96h21m58s
CI/CD Pipeline / PR Build Web Image (push) Failing after 96h22m29s
CI/CD Pipeline / Canary Release to Production (push) Failing after 96h13m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 96h13m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 96h22m30s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 96h22m33s
CI/CD Pipeline / Build Production API Image (push) Failing after 96h48m55s
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-12 15:00:56 +08:00
frontend-dev ed7af0642d fix(ai-avatar): B-roll文案显示时长+标题字号调大 (#1870)
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 2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m19s
CI/CD Pipeline / Validate - Style (push) Successful in 3m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m48s
CI/CD Pipeline / Validate - Security (push) Successful in 4m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m17s
CI/CD Pipeline / Unit Tests (push) Successful in 8m8s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 23m3s
CI/CD Pipeline / Deploy Production (push) Failing after 101h12m58s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 101h21m8s
CI/CD Pipeline / PR Build Web Image (push) Failing after 101h20m50s
CI/CD Pipeline / PR Build API Image (push) Failing after 101h20m50s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 101h12m40s
CI/CD Pipeline / Build Production Web Image (push) Failing after 101h12m40s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 101h20m19s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 101h20m19s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 101h20m19s
CI/CD Pipeline / Canary Release to Production (push) Failing after 100h56m10s
CI/CD Pipeline / CI Gate (push) Failing after 101h12m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 101h20m50s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 101h20m54s
CI/CD Pipeline / Build Production API Image (push) Failing after 101h47m53s
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-12 10:02:34 +08:00
frontend-dev 938ef0b8cc fix(ai-avatar): 端到端一致性修复(标题/封面/B-roll位置/B-roll时长) (#1869)
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 / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 51s
CI/CD Pipeline / Integration Tests (push) Successful in 3m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m37s
CI/CD Pipeline / Validate - Style (push) Successful in 4m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m29s
CI/CD Pipeline / Validate - Security (push) Successful in 8m25s
CI/CD Pipeline / Unit Tests (push) Successful in 8m59s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 18m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m7s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m42s
CI/CD Pipeline / CI Gate (push) Failing after 108h33m30s
CI/CD Pipeline / Build Production API Image (push) Failing after 108h33m30s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 108h41m37s
CI/CD Pipeline / PR Build Web Image (push) Failing after 108h42m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 108h33m12s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 108h42m18s
CI/CD Pipeline / PR Build API Image (push) Failing after 108h42m18s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 108h41m19s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 108h41m19s
CI/CD Pipeline / Canary Release to Production (push) Failing after 108h18m59s
CI/CD Pipeline / Deploy Production (push) Failing after 108h33m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 108h33m12s
CI/CD Pipeline / Frontend Lint (push) Failing after 108h42m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 108h42m19s
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-12 02:41:11 +08:00
frontend-dev 982daac6e5 Merge pull request 'fix(ai-avatar): 全盘修复 FFmpeg 渲染滤镜链路(exit 234 P0)' (#1868) from fix/ffmpeg-filter-comprehensive-fix into develop
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 6s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m2s
CI/CD Pipeline / Integration Tests (push) Successful in 7m24s
CI/CD Pipeline / Validate - Style (push) Successful in 9m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 9m56s
CI/CD Pipeline / Unit Tests (push) Successful in 12m12s
CI/CD Pipeline / Validate - Security (push) Successful in 23m10s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 39m39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 41m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m27s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 111h2m12s
CI/CD Pipeline / Deploy Production (push) Failing after 111h20m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 111h20m14s
CI/CD Pipeline / PR Build Web Image (push) Failing after 111h43m26s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 111h43m9s
CI/CD Pipeline / PR Build API Image (push) Failing after 111h43m9s
CI/CD Pipeline / Build Production API Image (push) Failing after 111h19m58s
CI/CD Pipeline / CI Gate (push) Failing after 111h19m57s
CI/CD Pipeline / Canary Release to Production (push) Failing after 110h55m52s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 111h1m55s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 111h1m55s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 111h19m57s
CI/CD Pipeline / Frontend Lint (push) Failing after 111h43m8s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 111h43m10s
2026-09-11 23:40:21 +08:00
22 changed files with 1500 additions and 290 deletions
@@ -0,0 +1,27 @@
"""add sentence_timings to lipsync_jobs
Revision ID: 075_add_sentence_timings
Revises: 074_ai_avatar_render_script_id_optional
Create Date: 2026-09-12
"""
import sqlalchemy as sa
from alembic import op
revision = "075_add_sentence_timings"
down_revision = "074_render_script_id_optional"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("lipsync_jobs") as batch:
batch.add_column(
sa.Column("sentence_timings", sa.JSON(), nullable=True),
)
def downgrade() -> None:
with op.batch_alter_table("lipsync_jobs") as batch:
batch.drop_column("sentence_timings")
+72 -2
View File
@@ -191,7 +191,6 @@ def retry_render_job(
return AiAvatarRenderJobResponse.model_validate(job)
# ── POST /smart-cover — 智能获取封面(MediaKit 抽帧 + 评分选帧)────────
@@ -220,7 +219,9 @@ def generate_avatar_smart_cover(
except Exception as exc:
logger.error(
"智能封面生成异常: user=%s video_url=%s err=%s",
current_user.user.id, video_url[:80], exc,
current_user.user.id,
video_url[:80],
exc,
exc_info=True,
)
cover_url = ""
@@ -233,3 +234,72 @@ def generate_avatar_smart_cover(
)
logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120])
return SmartCoverResponse(cover_url=cover_url, status="completed")
# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)────────
@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse)
def generate_render_smart_cover(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS).
- 必须等渲染任务 completed 后才可调用(否则返回 400)
- 生成成功后自动更新 render_job 的 cover_config 与 output_cover_url
"""
from app.services.ai_avatar_render_service import AiAvatarRenderService
svc = AiAvatarRenderService(db)
job = svc.get_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "completed":
raise HTTPException(status_code=400, detail="请先完成视频生成")
video_url = (job.output_video_url or "").strip()
if not video_url:
raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空")
try:
# 成片已叠加标题,不传 title_config 避免双重叠加
cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5)
except Exception as exc:
logger.error(
"渲染成片智能封面生成异常: user=%s render_id=%s video_url=%s err=%s",
current_user.user.id,
job_id,
video_url[:80],
exc,
exc_info=True,
)
cover_url = ""
if not cover_url:
return SmartCoverResponse(
cover_url="",
status="fallback_failed",
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
)
# 更新 render_job 的封面字段(异步写入 DB;失败不影响返回)
try:
job.cover_config = {
**(job.cover_config if isinstance(job.cover_config, dict) else {}),
"mode": "auto_frame",
"url": cover_url,
}
job.output_cover_url = cover_url
job.updated_at = datetime.now(timezone.utc)
db.commit()
except Exception as exc:
logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc)
logger.info(
"渲染成片智能封面生成成功: user=%s render_id=%s cover_url=%s",
current_user.user.id,
job_id,
cover_url[:120],
)
return SmartCoverResponse(cover_url=cover_url, status="completed")
+1
View File
@@ -33,6 +33,7 @@ class LipsyncJobResponse(BaseModel):
output_duration: float
error_message: str
error_code: str
sentence_timings: Optional[list] = None
submitted_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
@@ -197,9 +197,8 @@ class AiAvatarRenderService:
1. 下载对口型输出视频 (20%)
2. 构建 FFmpeg 滤镜链 (40%)
3. 执行 FFmpeg 渲染 (80%)
4. 提取封面 (90%)
5. 上传到 OSS (95%)
6. 更新任务状态 (100%)
4. 上传到 OSS (95%) — 封面不再自动生成,改由前端主动抽帧
5. 更新任务状态 (100%)
"""
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
if job is None:
@@ -311,64 +310,23 @@ class AiAvatarRenderService:
job.progress = 80
self.db.commit()
# 4. 提取封面 (90%)
cover_path = ""
if job.cover_config:
cover_path = os.path.join(tmpdir, "cover.jpg")
cover_cmd = self._build_cover_extract_cmd(
cover_config=job.cover_config,
input_video=output_video_path,
output_path=cover_path,
)
try:
cover_result = subprocess.run(
cover_cmd,
capture_output=True,
text=True,
timeout=60,
)
if cover_result.returncode != 0:
logger.warning(
"封面提取失败(非致命),跳过: exit=%s stderr=%s",
cover_result.returncode,
(cover_result.stderr or "")[-300:],
)
cover_path = ""
except Exception as cover_err:
logger.warning("封面提取异常(非致命),跳过: %s", cover_err)
cover_path = ""
job.progress = 90
self.db.commit()
# 5. 上传到 OSS (95%)
# 4/5. 上传成片到 OSS (95%) —— 已砍掉自动抽封面逻辑(步骤⑤);
# 封面由前端在渲染完成后通过 /smart-cover 接口主动从成片抽帧,不阻塞渲染链路。
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
job.output_video_url = output_video_url
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧(支持 drawtext 标题叠加);
# MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
smart_cover_url = ""
if output_video_url:
try:
from app.services.ai_avatar_cover_service import (
generate_smart_cover,
)
smart_cover_url = generate_smart_cover(
output_video_url,
job_id=job_id,
max_frames=5,
# 注意:不传 title_config —— 最终输出视频已经通过 drawtext 叠加了标题,
# 再传会导致封面标题双重叠加
)
except Exception:
logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True)
if smart_cover_url:
job.output_cover_url = smart_cover_url
elif cover_path:
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
job.output_cover_url = output_cover_url
# 封面透传:如果用户已在 cover_config 中选定封面 URLmode=upload 的自定义上传 或
# mode=auto_frame 已有的智能封面结果),直接透传到 output_cover_url,不再重新截帧。
if isinstance(job.cover_config, dict):
_pre_cover_url = (
job.cover_config.get("url")
or job.cover_config.get("imageUrl")
or job.cover_config.get("cover_url")
or ""
)
if _pre_cover_url:
job.output_cover_url = _pre_cover_url
logger.info("[数字人渲染] 使用用户已选定封面 URL: job_id=%s", job_id)
# 获取输出视频时长
job.output_duration = lipsync_job.output_duration
@@ -535,41 +493,6 @@ class AiAvatarRenderService:
)
return cmd
def _build_cover_extract_cmd(
self,
*,
cover_config: dict[str, Any],
input_video: str,
output_path: str,
) -> list[str]:
"""构建封面截帧 FFmpeg 命令(list 形式,shell=False."""
if not cover_config or not isinstance(cover_config, dict):
timestamp = 0.0
width = 0
height = 0
else:
timestamp = cover_config.get("timestamp", 0.0)
width = cover_config.get("width", 0)
height = cover_config.get("height", 0)
cmd: list[str] = [
"ffmpeg",
"-ss",
str(timestamp),
"-i",
input_video,
"-frames:v",
"1",
]
if width > 0 and height > 0:
vf = (
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
)
cmd.extend(["-vf", vf])
cmd.extend(["-y", output_path])
return cmd
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
"""上传文件到 OSS,返回 URL.
+27 -5
View File
@@ -198,6 +198,12 @@ class LipsyncService:
self.db.add(job)
self.db.flush()
# ⚠️ 必须先 commit 再发 Celery 任务,避免事务竞态:
# worker 是独立进程+独立DB连接,任务被消费(<4ms)时若本事务还未提交,
# worker 查询 job 会返回 None → 静默 return 不重试,job 永远卡在 tts_processing。
self.db.commit()
self.db.refresh(job)
if is_tts_mode:
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交
try:
@@ -223,6 +229,7 @@ class LipsyncService:
job.error_message = f"Celery 任务投递失败: {exc}"
job.error_code = "AsyncDispatchFailed"
job.updated_at = datetime.now(timezone.utc)
self.db.commit() # 投递失败也要落库失败状态
else:
# 2b. 直接音频模式:同步签名并提交 MediaKit
video_url = self._sign_media_url(video_url)
@@ -240,15 +247,15 @@ class LipsyncService:
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
self.db.commit() # submitted 状态落库
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
logger.error("提交对口型任务失败: %s", exc)
self.db.commit()
raise
self.db.commit()
self.db.refresh(job)
return job
# ── 查询任务 ──────────────────────────────────────────────────────────
@@ -313,11 +320,26 @@ class LipsyncService:
if mk_status == STATUS_COMPLETED:
result = status_data.get("result", {})
job.status = STATUS_COMPLETED
output_url = result.get("video_url", "")
# MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL
job.output_video_url = self._persist_output_video(output_url, job_id, user_id)
temp_url = result.get("video_url", "")
# 先以临时 URL 立即返回前端(前端可立即播放),再异步 Celery 任务转存自家 OSS(步骤⑦
job.output_video_url = temp_url
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
# 异步转存到自家 OSS(注意:必须在 commit 之后 dispatch,避免 commit 失败任务已发出)
try:
from app.tasks.lipsync_tts import persist_output_video_task
persist_output_video_task.apply_async(args=(job_id, user_id, temp_url))
except Exception as exc:
logger.warning(
"提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s",
job_id,
exc,
)
self.db.refresh(job)
return job
elif mk_status == STATUS_FAILED:
error = status_data.get("error", {})
job.status = "failed"
+337 -14
View File
@@ -54,11 +54,170 @@ def _sign_media_url(url: str) -> str:
return url
def _split_script_into_sentences(script_text: str) -> list[str]:
"""按句号/问号/感叹号/分号/换行分句(与前端 splitScriptIntoSentences 一致)."""
import re
text = (script_text or "").strip()
if not text:
return []
parts = re.split(r"[。!?!?;\n\r]+", text)
return [p.strip() for p in parts if p.strip()]
def _compute_sentence_timings(audio_data: bytes, script_text: str, total_duration: float) -> list[dict]:
"""基于 TTS 音频的静音检测,精确计算每句文案的起止时间.
使用 ffmpeg silencedetect 检测静音段,将静音点与句子边界对齐。
比字数比例估算准确得多。
Args:
audio_data: TTS 音频二进制数据(MP3
script_text: 文案全文
total_duration: 音频总时长(秒)
Returns:
list[{"index": int, "text": str, "start_time": float, "end_time": float}]
"""
import re
import subprocess
import tempfile
sentences = _split_script_into_sentences(script_text)
if not sentences:
return []
# 写入临时音频文件
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
tmp.write(audio_data)
tmp_path = tmp.name
try:
# 用 ffmpeg silencedetect 检测静音段
result = subprocess.run(
[
"ffmpeg",
"-i",
tmp_path,
"-af",
"silencedetect=noise=-25dB:d=0.3",
"-f",
"null",
"-",
],
capture_output=True,
text=True,
timeout=30,
)
stderr = result.stderr or ""
# 解析静音结束时间点(silence_end: X.XXX
silence_ends = []
for match in re.finditer(r"silence_end:\s*([\d.]+)", stderr):
t = float(match.group(1))
if 0 < t < total_duration:
silence_ends.append(t)
# 如果没有检测到足够的静音点,降级为字数比例估算
if len(silence_ends) < len(sentences) - 1:
logger.warning(
"[sentence_timings] 静音点不足(%d < %d),降级为字数比例估算",
len(silence_ends),
len(sentences) - 1,
)
return _estimate_sentence_timings_by_chars(sentences, total_duration)
# 贪心匹配:N-1 个句子边界对应 N-1 个静音点
# 按时间均匀分布期望值,选择最近的静音点
n_boundaries = len(sentences) - 1
boundaries = []
used_indices = set()
for i in range(n_boundaries):
# 期望的边界位置(按句子数量均匀分布)
expected_pos = (i + 1) / len(sentences) * total_duration
# 找最近的未使用静音点
best_idx = None
best_dist = float("inf")
for j, t in enumerate(silence_ends):
if j in used_indices:
continue
dist = abs(t - expected_pos)
if dist < best_dist:
best_dist = dist
best_idx = j
if best_idx is not None:
used_indices.add(best_idx)
boundaries.append(silence_ends[best_idx])
boundaries.sort()
# 构建 sentence_timings
timings = []
prev_end = 0.0
for i, sent in enumerate(sentences):
start = prev_end
end = boundaries[i] if i < len(boundaries) else total_duration
timings.append(
{
"index": i,
"text": sent,
"start_time": round(start, 2),
"end_time": round(end, 2),
}
)
prev_end = end
return timings
except Exception as exc:
logger.warning("[sentence_timings] 静音检测异常,降级为字数比例估算: %s", exc)
return _estimate_sentence_timings_by_chars(sentences, total_duration)
finally:
import os
try:
os.unlink(tmp_path)
except Exception:
pass
def _estimate_sentence_timings_by_chars(sentences: list[str], total_duration: float) -> list[dict]:
"""降级方案:按字数比例估算句子时间(与原前端逻辑一致)."""
if not sentences or total_duration <= 0:
return []
total_chars = sum(len(s.replace(r"\s", "")) for s in sentences)
if total_chars == 0:
return []
timings = []
acc = 0
for i, sent in enumerate(sentences):
chars = len(sent.replace(r"\s", ""))
start = (acc / total_chars) * total_duration
end = ((acc + chars) / total_chars) * total_duration
timings.append(
{
"index": i,
"text": sent,
"start_time": round(start, 2),
"end_time": round(end, 2),
}
)
acc += chars
return timings
@shared_task(
bind=True,
name="lipsync_tts.synthesize_and_submit",
max_retries=2,
max_retries=5, # 事务竞态重试3次(job not found+ TTS偶发错误2次
default_retry_delay=30,
autoretry_for=(OSError, ConnectionError), # 网络/连接错误自动重试
retry_backoff=True,
retry_backoff_max=30,
soft_time_limit=180,
time_limit=200,
)
def tts_synthesize_and_submit(
self,
@@ -103,7 +262,28 @@ def tts_synthesize_and_submit(
)
if job is None:
logger.error("[lipsync_tts] Job not found: job_id=%s", job_id)
# 事务竞态防御:API 在 commit 前投递了任务,worker 消费时事务尚未提交。
# Celery 内置 autoretry_for 不支持"业务条件重试",这里手动 retry 3 次,
# 间隔递增(1s/3s/7s),让 API 事务有时间提交。
# max_retries 由 self.request(retries) 维护;默认 self.max_retries=3 由装饰器 soft_time_limit 下方指定。
retries = getattr(self.request, "retries", 0)
max_retries = 3
if retries < max_retries:
backoff = (2**retries) + (retries * 1) # 1s, 3s, 7s
logger.warning(
"[lipsync_tts] Job not found yet (retry %d/%d, backoff %ds): job_id=%s",
retries + 1,
max_retries,
backoff,
job_id,
)
self.db.close()
raise self.retry(countdown=backoff, max_retries=max_retries)
logger.error(
"[lipsync_tts] Job not found after %d retries, giving up: job_id=%s",
max_retries,
job_id,
)
return
# 已取消的任务不再处理
@@ -112,6 +292,13 @@ def tts_synthesize_and_submit(
return
# 1. TTS 合成
logger.info(
"[lipsync_tts] 开始 TTS 合成: job_id=%s voice_id=%s text_len=%d speed=%.2f",
job_id,
voice_id,
len(script_text),
speed,
)
try:
cosyvoice = CosyVoiceService()
result = cosyvoice.submit_synthesize_task(
@@ -147,38 +334,113 @@ def tts_synthesize_and_submit(
db.commit()
return
# 2. 下载转存自家 OSS
# 2. 下载 TTS 音频到内存(用于 2.5 静音检测;不转存自家 OSS,直接使用 CosyVoice 临时 URL
audio_data: bytes | None = None
_st_tmp_path: str | None = None
try:
audio_data = safe_download_bytes(
temp_url,
purpose="lipsync_tts_audio",
allowed_mime_types=(
allowed_mime_types={
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav", # CosyVoice 部分接口返回 audio/x-wav,与 audio/wav 等价(RIFF/WAVE
"audio/mp4",
"audio/x-m4a",
),
},
timeout=60.0,
)
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
logger.info("[lipsync_tts] TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
job.audio_url = permanent_url
logger.info(
"[lipsync_tts] TTS 音频已下载到内存: job_id=%s size=%d",
job_id,
len(audio_data) if audio_data else 0,
)
except Exception as exc:
# 下载失败:audio_data 保持 None2.5 静音检测会跳过;后续仍用 temp_url 提交 MediaKit
logger.warning(
"[lipsync_tts] TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s",
"[lipsync_tts] TTS 音频下载失败,跳过静音检测,直接使用临时 URL 提交: job_id=%s err=%s",
job_id,
exc,
)
job.audio_url = temp_url
# TTS 音频使用 CosyVoice 临时 URL,跳过自家 OSS 转存(加速,步骤⑥)
job.audio_url = temp_url
logger.info("[lipsync_tts] TTS 音频使用 CosyVoice 临时 URL(跳过 OSS 转存): job_id=%s", job_id)
db.commit()
# 2.5 计算精确句子时间戳(基于 TTS 音频静音检测)
# 直接复用步骤 2 已下载到内存的 audio_data,避免重新下载
import os as _os
try:
import subprocess as _sp
import tempfile as _tmpf
if not audio_data:
logger.warning("[lipsync_tts] 无音频数据,跳过句子时间戳计算: job_id=%s", job_id)
else:
# 写入临时文件供 ffprobe/ffmpeg 使用
with _tmpf.NamedTemporaryFile(suffix=".mp3", delete=False) as _atmp:
_atmp.write(audio_data)
_st_tmp_path = _atmp.name
# ffprobe 获取音频时长
_probe_result = _sp.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
_st_tmp_path,
],
capture_output=True,
text=True,
timeout=10,
)
_audio_duration = float(_probe_result.stdout.strip()) if _probe_result.stdout.strip() else 0.0
logger.info(
"[lipsync_tts] 音频时长探测: job_id=%s duration=%.2f probe_stdout=%s probe_stderr=%s",
job_id,
_audio_duration,
_probe_result.stdout.strip()[:50],
_probe_result.stderr.strip()[:100] if _probe_result.stderr else "",
)
if _audio_duration > 0:
_timings = _compute_sentence_timings(audio_data, script_text, _audio_duration)
if _timings:
job.sentence_timings = _timings
logger.info(
"[lipsync_tts] 句子时间戳已计算: job_id=%s sentences=%d duration=%.1f",
job_id,
len(_timings),
_audio_duration,
)
else:
logger.warning("[lipsync_tts] 句子时间戳计算返回空结果: job_id=%s", job_id)
else:
logger.warning(
"[lipsync_tts] ffprobe 未获取到有效时长,跳过句子时间戳: job_id=%s stdout=%s stderr=%s",
job_id,
_probe_result.stdout.strip()[:100],
_probe_result.stderr.strip()[:200] if _probe_result.stderr else "",
)
db.commit()
except Exception as _st_err:
logger.warning(
"[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True
)
finally:
if _st_tmp_path:
try:
_os.unlink(_st_tmp_path)
except Exception:
pass
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
audio_url = _sign_media_url(job.audio_url)
video_url = _sign_media_url(job.video_url)
@@ -221,3 +483,64 @@ def tts_synthesize_and_submit(
logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id)
finally:
db.close()
@shared_task(
name="lipsync_tts.persist_output_video",
max_retries=2,
default_retry_delay=30,
)
def persist_output_video_task(job_id: str, user_id: str, temp_url: str):
"""异步转存对口型输出视频到自家 OSS(步骤⑦ — 将同步阻塞挪到后台,加速前端响应).
- MediaKit 返回 completed 后先以 temp_url 回前端(前端可立即播放临时 URL)
- Celery 后台下载 temp_url 并转存 OSS,成功后更新 job.output_video_url 为永久 URL
- 失败则保留 temp_url,不阻断主流程
"""
try:
from worker_app.db import SessionLocal # type: ignore
except Exception: # noqa: BLE001
from app.db import SessionLocal # type: ignore
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.shared.storage import get_shared_storage_service
db = SessionLocal()
try:
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first()
if job is None:
logger.error("[lipsync_tts.persist] Job not found: job_id=%s", job_id)
return
if not temp_url:
logger.warning("[lipsync_tts.persist] temp_url 为空,跳过转存: job_id=%s", job_id)
return
try:
import httpx
with httpx.Client(timeout=180.0, follow_redirects=True) as client:
resp = client.get(temp_url)
resp.raise_for_status()
data = resp.content
storage = get_shared_storage_service()
storage_key = f"lipsync-outputs/{user_id}/{job_id}.mp4"
permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4")
# 对自家 OSS URL 重签 7 天有效期预签名,供前端播放
final_url = _sign_media_url(permanent_url) if permanent_url else temp_url
job.output_video_url = final_url
job.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info("[lipsync_tts.persist] 输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
except Exception as exc:
logger.warning(
"[lipsync_tts.persist] 输出视频转存失败,保留临时 URL: job_id=%s err=%s",
job_id,
exc,
)
except Exception:
logger.exception("[lipsync_tts.persist] 未预期异常: job_id=%s", job_id)
finally:
db.close()
+65 -37
View File
@@ -23,9 +23,10 @@ import {
getLipsyncJob,
submitRender,
getRenderJob,
generateSmartCover,
generateRenderSmartCover,
} from "./api/aiAvatar"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { RenderJob } from "./types"
import {
normalizeEmotion,
buildTitleConfigPayload,
@@ -54,8 +55,6 @@ const AiAvatarPage: React.FC = () => {
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ── 智能封面加载态 ── */
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/* ── 渲染进度弹窗 ── */
const [showRenderModal, setShowRenderModal] = useState(false)
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
@@ -63,6 +62,8 @@ const AiAvatarPage: React.FC = () => {
)
const [renderProgress, setRenderProgress] = useState(0)
const [renderErrorMessage, setRenderErrorMessage] = useState("")
/* ── 当前渲染任务对象(轮询更新;用于封面区判断渲染是否完成) ── */
const [currentRenderJob, setCurrentRenderJob] = useState<RenderJob | null>(null)
/* ── 对口型轮询 ── */
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -223,7 +224,12 @@ const AiAvatarPage: React.FC = () => {
pip_scale: seg.pip_scale,
})) as never,
title_config: buildTitleConfigPayload(state.titleConfig),
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
// 封面不阻塞渲染:用户未选定封面时传空 dict,后端不生成封面;渲染完成后再单独抽帧
cover_config:
state.coverConfig.smart_cover_url ||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
? buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url)
: {},
})
// 打开渲染进度弹窗,启动轮询
@@ -231,16 +237,28 @@ const AiAvatarPage: React.FC = () => {
setRenderStatus("generating")
setRenderProgress(job.progress ?? 0)
setRenderErrorMessage("")
setCurrentRenderJob(job as RenderJob)
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = setInterval(async () => {
try {
const updated = await getRenderJob(job.id)
setRenderProgress(updated.progress ?? 0)
setCurrentRenderJob(updated)
if (updated.status === "completed") {
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = null
setRenderStatus("completed")
// 渲染完成后:如果后端已返回封面(用户预上传/预设)则同步到前端;
// 否则不自动设置封面,由用户在封面区点击"智能获取封面"主动抽帧(步骤③④)
if (updated.output_cover_url) {
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: updated.output_cover_url,
thumbnail_url: updated.output_cover_url,
}))
}
message.success("视频已生成并保存到成片库")
} else if (updated.status === "failed") {
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
@@ -273,38 +291,48 @@ const AiAvatarPage: React.FC = () => {
setRenderErrorMessage("")
}, [])
/* ── 智能封面:调后端 MediaKit 选帧接口(#1822 ── */
const handleSmartCover = useCallback(async () => {
// 基于对口型成片抽帧,必须先完成对口型
const videoUrl = state.lipsyncJob?.output_video_url
if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
message.warning("请先生成对口型视频,完成后再智能获取封面")
return
}
setSmartCoverLoading(true)
try {
const res = await generateSmartCover(videoUrl, buildTitleConfigPayload(state.titleConfig), 5)
if (res.cover_url) {
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
}))
message.success("智能封面已生成")
} else {
message.error(res.message || "智能封面生成失败,请稍后重试")
/* ── 智能封面:从最终渲染成片抽帧(POST /renders/{id}/smart-cover,步骤③④ ── */
const handleGenerateRenderSmartCover = useCallback(
async (renderId: string): Promise<{ cover_url: string; message?: string }> => {
try {
const res = await generateRenderSmartCover(renderId)
if (res.cover_url) {
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
}))
message.success("智能封面已生成")
return { cover_url: res.cover_url }
}
const errMsg = res.message || "智能封面生成失败,请稍后重试"
message.error(errMsg)
return { cover_url: "", message: errMsg }
} catch (err) {
console.error("智能封面生成失败:", err)
const errMsg = err instanceof Error ? err.message : "智能封面生成失败,请重试"
message.error(errMsg)
return { cover_url: "", message: errMsg }
}
} catch (err) {
console.error("智能封面生成失败:", err)
message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试")
} finally {
setSmartCoverLoading(false)
}
},
// state.setCoverConfig 是 zustand action 引用稳定,eslint 不需要检查
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.lipsyncJob])
[],
)
/* ── 配置汇总 ── */
const coverStatus: "not_ready" | "pending" | "selected" = (() => {
if (
state.coverConfig.smart_cover_url ||
state.coverConfig.thumbnail_url ||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
) {
return "selected"
}
if (currentRenderJob?.status === "completed") return "pending"
return "not_ready"
})()
const summary = {
videoName: state.selectedVideo?.name || null,
voiceName: state.selectedVoice?.name || null,
@@ -312,7 +340,7 @@ const AiAvatarPage: React.FC = () => {
lipsyncStatus: state.lipsyncJob?.status || null,
brollCount: state.bRollSegments.length,
hasTitle: state.titleConfig.title.length > 0,
hasCover: state.coverConfig.enabled,
coverStatus,
}
return (
@@ -448,9 +476,8 @@ const AiAvatarPage: React.FC = () => {
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
titleConfig={state.titleConfig}
onSmartCover={handleSmartCover}
smartCoverLoading={smartCoverLoading}
canSmartCover={state.lipsyncJob?.status === "completed"}
renderJob={currentRenderJob}
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
@@ -488,8 +515,9 @@ const AiAvatarPage: React.FC = () => {
open={state.showBRollModal}
onClose={() => state.setShowBRollModal(false)}
existingSegments={state.bRollSegments}
scriptText={state.scriptText}
scriptText={state.lipsyncJob?.script_text || state.scriptText}
outputDuration={state.lipsyncJob?.output_duration ?? 0}
sentenceTimings={state.lipsyncJob?.sentence_timings}
onConfirm={state.addBRollSegment}
onRemove={state.removeBRollSegment}
/>
@@ -94,3 +94,16 @@ export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
export const cancelRenderJob = async (jobId: string): Promise<void> => {
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
}
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover ── */
export const generateRenderSmartCover = async (
jobId: string,
): Promise<{ cover_url: string; status: string; message: string }> => {
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
`/ai-avatar/render/${jobId}/smart-cover`,
{},
// 抽帧+评分+转存 OSS 链路较长,120s 超时
{ timeout: 120000 },
)
return response.data
}
@@ -5,12 +5,12 @@
* - 左侧:先选素材库(video 库)→ 再选该库视频素材(已被其他 segment 使用的素材
* 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择)
* - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小
* (开始/结束时间已删除,按句子字数占比 × 口播总时长自动估算
* (开始/结束时间来自后端精确句子时间戳,基于 TTS 音频静音检测
* - 底部:已配置的画面插入列表(可删除)
*/
import React, { useEffect, useMemo, useState } from "react"
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
import type { BRollSegment, BRollInsertMode, PipPosition, SentenceTiming } from "../types"
import { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
interface ModalBRollEditorProps {
@@ -18,10 +18,12 @@ interface ModalBRollEditorProps {
onClose: () => void
/** 当前已有的 B-roll segments(用于标灰已选素材) */
existingSegments: BRollSegment[]
/** 当前文案全文(用于分句 */
/** 文案全文(优先使用对口型时锁定的 scriptText */
scriptText: string
/** 对口型成片总时长(秒),用于时间自动估算 */
/** 对口型成片总时长(秒) */
outputDuration: number
/** 后端精确句子时间戳(来自 lipsyncJob.sentence_timings */
sentenceTimings?: SentenceTiming[] | null
onConfirm: (segment: BRollSegment) => void
onRemove: (id: string) => void
}
@@ -43,7 +45,8 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
onClose,
existingSegments,
scriptText,
outputDuration,
outputDuration: _outputDuration,
sentenceTimings,
onConfirm,
onRemove,
}) => {
@@ -62,10 +65,10 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
const [pipScale, setPipScale] = useState(0.3)
/** 文案分句( */
/** 文案分句(优先使用后端精确时间戳,降级为字数比例估算 */
const sentences = useMemo(
() => splitScriptIntoSentences(scriptText, outputDuration),
[scriptText, outputDuration],
() => splitScriptIntoSentences(scriptText, sentenceTimings, _outputDuration),
[scriptText, sentenceTimings, _outputDuration],
)
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
@@ -142,7 +145,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
setSelectedAsset(asset)
}
/** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止 */
/** 确认添加一段 B-roll(⑥ 时间取所选句子的精确起止,后端静音检测 / 前端字数比例降级 */
const handleConfirm = () => {
if (!selectedAsset || !selectedSentence) return
const startTime = selectedSentence.startTime
@@ -264,11 +267,9 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
>
<span className="aa-sentence-item__idx">{sent.index + 1}</span>
<span className="aa-sentence-item__text">{sent.text}</span>
{outputDuration > 0 && (
<span className="aa-sentence-item__time">
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
</span>
)}
<span className="aa-sentence-item__time">
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
</span>
</button>
)
})}
@@ -349,7 +350,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
selectedSentence.endTime,
selectedSentence.startTime + 0.5,
).toFixed(1)}
s
s
</div>
</>
) : (
@@ -1,14 +1,15 @@
/**
* AI数字人 — 面板5:封面 & 生成
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)+ 标题文字实时叠加预览
* - 分辨率选择(720p / 1080p / 4K
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
* - 渐变紫色生成按钮
* AI数字人 — 面板5分辨率/配置摘要/生成按钮/封面
* v3 调整(步骤③④):
* - 布局顺序:分辨率 → 配置摘要卡片 → 🔘「开始生成视频」按钮 → (渲染完成后)封面区域
* - 渲染未完成时封面区域显示占位态,按钮 disabled
* - 「智能获取封面」从最终成片抽帧(调用 POST /renders/{id}/smart-cover),不再依赖 lipsync 状态
* - 修复点 2 次 bug:内部维护 smartCoverLoading,不依赖外层异步 state 更新
*
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
*/
import React, { useMemo, useRef } from "react"
import type { AiAvatarCoverConfig, AiAvatarTitleConfig } from "../types"
import React, { useMemo, useRef, useState } from "react"
import type { AiAvatarCoverConfig, AiAvatarTitleConfig, RenderJob } from "../types"
interface PanelCoverAndGenerateProps {
coverConfig: AiAvatarCoverConfig
@@ -18,10 +19,12 @@ interface PanelCoverAndGenerateProps {
onResolutionChange: (r: string) => void
isGenerating: boolean
onGenerate: () => void
/** 智能获取封面(MediaKit 选帧 */
onSmartCover: () => void
smartCoverLoading: boolean
canSmartCover: boolean
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面 */
renderJob: RenderJob | null
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */
onUploadCover?: (file: File) => void
/** 配置汇总信息 */
summary: {
videoName: string | null
@@ -30,7 +33,8 @@ interface PanelCoverAndGenerateProps {
lipsyncStatus: string | null
brollCount: number
hasTitle: boolean
hasCover: boolean
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
coverStatus: "not_ready" | "pending" | "selected"
}
}
@@ -66,12 +70,14 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
onResolutionChange,
isGenerating,
onGenerate,
onSmartCover,
smartCoverLoading,
canSmartCover,
renderJob,
onGenerateRenderSmartCover,
onUploadCover,
summary,
}) => {
const uploadInputRef = useRef<HTMLInputElement>(null)
// 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序)
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/** 自定义上传封面 */
const handleUploadClick = () => {
@@ -81,22 +87,44 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 本地预览:生成 object URL(实际上传由父级/后端链路处理)
const url = URL.createObjectURL(file)
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
// 允许重复选择同一文件
if (onUploadCover) {
onUploadCover(file)
} else {
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
const url = URL.createObjectURL(file)
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
}
e.target.value = ""
}
/** 智能获取封面(调后端 MediaKit 抽帧评分选最佳帧,#1822 */
const handleSmartCover = () => {
onCoverConfigChange({ mode: "auto_frame" })
onSmartCover()
/** 智能获取封面(从最终成片抽帧;必须等 render 完成 */
const handleSmartCover = async () => {
if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return
setSmartCoverLoading(true)
try {
const res = await onGenerateRenderSmartCover(renderJob.id)
if (res.cover_url) {
onCoverConfigChange({
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
})
} else {
// 失败由父组件 message 提示,这里不重复弹窗
console.warn("[智能封面] 返回空 cover_url:", res.message)
}
} catch (err) {
console.error("[智能封面] 调用失败:", err)
} finally {
setSmartCoverLoading(false)
}
}
const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
// 渲染已完成 → 封面区可用
const isRenderCompleted = renderJob?.status === "completed"
const canSmartCover = isRenderCompleted && !smartCoverLoading
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
const coverUrl =
@@ -120,7 +148,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
wordBreak: "break-word",
whiteSpace: "pre-wrap",
color: titleConfig.color || "#ffffff",
fontSize: `${titleConfig.size}px`,
fontSize: `${(titleConfig.size || 48) * 0.35}px`,
fontFamily: getFontFamily(titleConfig.font),
fontWeight: titleConfig.bold ? "bold" : "normal",
fontStyle: titleConfig.italic ? "italic" : "normal",
@@ -128,7 +156,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
pointerEvents: "none",
}
// 位置
const pos = titleConfig.position || "bottom"
if (pos === "top") {
style.top = "40px"
@@ -136,7 +163,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
style.top = "50%"
style.transform = "translate(-50%, -50%)"
} else if (pos === "custom" && titleConfig.pos_x != null && titleConfig.pos_y != null) {
// pos_x/pos_y 是相对预览容器的百分比坐标
style.left = `${titleConfig.pos_x}%`
style.top = `${titleConfig.pos_y}%`
style.transform = "translate(-50%, -50%)"
@@ -144,67 +170,35 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
style.bottom = "40px"
}
// 描边优先于阴影(二者互斥,与 drawtext 对齐)
if (titleConfig.stroke) {
// 描边宽度按字号估算,保证视觉一致
const strokeWidth = Math.max(1, Math.round(titleConfig.size / 18))
;(style as React.CSSProperties)["WebkitTextStroke"] = `${strokeWidth}px rgba(0,0,0,0.75)`
style.textShadow = "none"
} else if (titleConfig.shadow) {
style.textShadow = "0 2px 8px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.5)"
} else {
// 默认给轻微阴影保证白字在亮背景可读
style.textShadow = "0 2px 6px rgba(0,0,0,0.6)"
style.textShadow = "none"
}
return style
}, [titleConfig])
/** 封面区占位文字 */
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
/** 封面摘要状态文本 */
const coverSummaryNode = (() => {
if (summary.coverStatus === "selected") {
return <span className="aa-config-summary__value"></span>
}
if (summary.coverStatus === "pending") {
return <span className="aa-config-summary__value"></span>
}
return <span className="aa-config-summary__empty"></span>
})()
return (
<div className="aa-cover-generate">
{/* 封面预览(竖屏 9:16 */}
<div className="aa-cover-preview">
{hasCoverImage ? (
<img src={coverUrl!} alt="封面预览" draggable={false} />
) : (
<span className="aa-cover-preview__placeholder"></span>
)}
{/* 智能封面加载遮罩 */}
{smartCoverLoading && <div className="aa-cover-preview__loading"> </div>}
{/* 标题文字叠加层(实时预览,仅前端视觉参考,最终由后端 ffmpeg drawtext 叠加) */}
{showTitleOverlay && (
<div style={titleOverlayStyle} aria-hidden="true">
{titleConfig.title}
</div>
)}
</div>
<div className="aa-cover-actions">
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleSmartCover}
disabled={smartCoverLoading || !canSmartCover}
title={canSmartCover ? "基于对口型成片智能选帧" : "请先完成对口型生成"}
>
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button>
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
onClick={handleUploadClick}
>
📷
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
{/* 分辨率选择 */}
<div className="aa-form-field">
<label className="aa-label"></label>
@@ -212,6 +206,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
className="aa-select"
value={resolution}
onChange={(e) => onResolutionChange(e.target.value)}
disabled={isGenerating}
>
{RESOLUTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -272,11 +267,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
</div>
<div className="aa-config-summary__row">
<span></span>
{summary.hasCover ? (
<span className="aa-config-summary__value"></span>
) : (
<span className="aa-config-summary__empty"></span>
)}
{coverSummaryNode}
</div>
</div>
@@ -294,6 +285,60 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
</div>
)}
{isGenerating && (
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
</div>
)}
</div>
{/* 封面区域(视频生成后才激活;步骤③④要求:按钮在封面上方,完成后再显示封面区) */}
<div className="aa-cover-section" style={{ marginTop: 16 }}>
<div className="aa-label" style={{ marginBottom: 8 }}>
</div>
{/* 封面预览(竖屏 9:16 */}
<div className="aa-cover-preview" style={{ opacity: isRenderCompleted ? 1 : 0.5 }}>
{hasCoverImage ? (
<img src={coverUrl!} alt="封面预览" draggable={false} />
) : (
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
)}
{smartCoverLoading && <div className="aa-cover-preview__loading"> </div>}
{showTitleOverlay && (
<div style={titleOverlayStyle} aria-hidden="true">
{titleConfig.title}
</div>
)}
</div>
<div className="aa-cover-actions">
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleSmartCover}
disabled={!canSmartCover}
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
>
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button>
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
onClick={handleUploadClick}
disabled={!isRenderCompleted || smartCoverLoading}
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
>
📷
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
</div>
)
@@ -14,8 +14,8 @@ interface PanelLipsyncPreviewProps {
onRemoveBRoll: (id: string) => void
/** 标题配置(实时叠加预览用) */
titleConfig?: AiAvatarTitleConfig
/** 标题位置变更回调(拖拽结束时调用) */
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number }) => void
/** 标题位置变更回调(拖拽结束时调用,发送百分比坐标 + position:"custom" */
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number; position: string }) => void
}
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
@@ -56,11 +56,9 @@ export function PanelLipsyncPreview({
const titleOverlayStyle: React.CSSProperties | null = titleConfig?.title
? {
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
color: titleConfig.color || "#ffffff",
fontFamily: titleConfig.font || "思源黑体",
fontSize: `${(titleConfig.size || 36) * 0.55}px`, // 预览等比缩
fontSize: `${(titleConfig.size || 48) * 0.35}px`,
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textAlign: "center",
@@ -68,11 +66,19 @@ export function PanelLipsyncPreview({
padding: "4px 8px",
textShadow: titleConfig.shadow ? "0 2px 4px rgba(0,0,0,0.8)" : undefined,
WebkitTextStroke: titleConfig.stroke ? "1.5px #000" : undefined,
...(titleConfig.position === "top"
? { top: 8 }
: titleConfig.position === "bottom"
? { bottom: 8 }
: { top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
...(titleConfig.position === "custom" &&
titleConfig.pos_x != null &&
titleConfig.pos_y != null
? {
left: `${titleConfig.pos_x}%`,
top: `${titleConfig.pos_y}%`,
transform: "translateX(-50%) translateY(-50%)",
}
: titleConfig.position === "top"
? { left: "50%", top: 8, transform: "translateX(-50%)" }
: titleConfig.position === "bottom"
? { left: "50%", bottom: 8, transform: "translateX(-50%)" }
: { left: "50%", top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
}
: null
@@ -105,7 +111,10 @@ export function PanelLipsyncPreview({
const rect = previewContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
onTitlePositionChange({ pos_x: relX, pos_y: relY })
// 发送百分比坐标(0-100),与后端 drawtext 百分比表达式对齐
const xpct = Math.round((relX / rect.width) * 1000) / 10
const ypct = Math.round((relY / rect.height) * 1000) / 10
onTitlePositionChange({ pos_x: xpct, pos_y: ypct, position: "custom" })
}
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
}
+15 -3
View File
@@ -44,12 +44,23 @@ export interface LipsyncJob {
status: LipsyncStatus
progress: number
output_video_url: string | null
/** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
/** 对口型成片总时长(秒),后端返回 */
script_text: string
output_duration?: number
/** 精确句子时间戳(后端基于 TTS 音频静音检测计算) */
sentence_timings?: SentenceTiming[] | null
error_message: string | null
created_at: string
}
/* ── 句子时间戳(后端精确计算) ── */
export interface SentenceTiming {
index: number
text: string
start_time: number
end_time: number
}
/* ── B-roll 画面插入 ── */
export type BRollInsertMode = "fullscreen" | "pip"
export type PipPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"
@@ -77,7 +88,7 @@ export interface AiAvatarTitleConfig {
shadow: boolean
color: string
auto_subtitle: boolean
/** 自定义位置坐标(position=custom 时生效,像素 */
/** 自定义位置坐标(position=custom 时生效,百分比 0-100 */
pos_x?: number
pos_y?: number
}
@@ -101,6 +112,7 @@ export interface RenderJob {
status: RenderStatus
progress: number
output_video_url: string | null
output_cover_url: string | null
error_message: string | null
created_at: string
}
@@ -110,7 +122,7 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
size: 48,
bold: true,
italic: false,
stroke: false,
@@ -39,7 +39,7 @@ export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string
text,
enabled: true,
font: cfg.font || "思源黑体",
font_size: Math.round(cfg.size) || 36,
font_size: Math.round(cfg.size) || 48,
font_color: cfg.color || "#ffffff",
position,
bold: !!cfg.bold,
@@ -67,9 +67,14 @@ export function buildCoverConfigPayload(
// build_cover_extract_command 读取 timestamp(截帧秒数)
timestamp: cfg.frame_time || 0,
}
if (smartCoverUrl) payload.cover_url = smartCoverUrl
// 智能封面 URL(后端字段名为 url/imageUrl/cover_url 都兼容,优先 url
if (smartCoverUrl) {
payload.url = smartCoverUrl
payload.cover_url = smartCoverUrl
}
// 自定义上传:blob: 本地预览地址无法给后端,仅 OSS URL 可用
if (cfg.mode === "upload" && cfg.upload_url && !cfg.upload_url.startsWith("blob:")) {
payload.url = cfg.upload_url
payload.upload_url = cfg.upload_url
}
return payload
@@ -1,5 +1,8 @@
/**
* AI数字人 — 文案分句 & B-roll 时间自动估算(#1809 ⑤⑥)
* AI数字人 — 文案分句 & B-roll 时间计算
*
* 优先使用后端基于 TTS 音频静音检测计算的精确 sentence_timings
* 后端未返回(如对口型还在生成中)时,降级为前端按字数比例估算。
*/
export interface ScriptSentence {
@@ -11,28 +14,63 @@ export interface ScriptSentence {
charCount: number
/** 累计起始字数(用于时间估算) */
startChar: number
/** 估算的对口型视频内起始时间(秒) */
/** 对口型视频内起始时间(秒)——后端精确值或前端估算 */
startTime: number
/** 估算的对口型视频内结束时间(秒) */
/** 对口型视频内结束时间(秒)——后端精确值或前端估算 */
endTime: number
}
/**
* 按句号/问号/感叹号/分号/换行分句(兼容中英文标点)。
* 空文案返回空数组。时间按「该句字数 ÷ 全文总字数 × 口播总时长」线性估算。
* 空文案返回空数组。时间优先使用后端 sentence_timings;否则按字数线性估算。
*
* @param sentenceTimings 后端返回的精确句子时间戳(来自 lipsync_job.sentence_timings)。
* 非空且有效时优先采用,跳过前端估算。
*/
export function splitScriptIntoSentences(
scriptText: string,
outputDuration: number,
sentenceTimings?: { index: number; text: string; start_time: number; end_time: number }[] | null,
outputDuration: number = 0,
): ScriptSentence[] {
const text = (scriptText || "").trim()
if (!text) return []
// 1. 先做基础分句(仅用于降级估算 / 没有 sentenceTimings 时)
const rawParts = text
.split(/[。!?!?;\n\r]+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
// 2. 优先使用后端精确时间戳
// 校验:必须是数组、条数一致、每条都有 start_time/end_time,否则降级估算
if (Array.isArray(sentenceTimings) && sentenceTimings.length === rawParts.length) {
const valid = sentenceTimings.every(
(t) =>
t &&
typeof t.start_time === "number" &&
typeof t.end_time === "number" &&
t.end_time >= t.start_time,
)
if (valid) {
let accChar = 0
return sentenceTimings.map((t, i) => {
const part = rawParts[i] ?? t.text ?? ""
const charCount = part.replace(/\s/g, "").length
const sentence: ScriptSentence = {
index: t.index ?? i,
text: part,
charCount,
startChar: accChar,
startTime: round1(t.start_time),
endTime: round1(t.end_time),
}
accChar += charCount
return sentence
})
}
}
// 3. 降级:按字数比例线性估算
const totalChars = rawParts.reduce((sum, part) => sum + part.replace(/\s/g, "").length, 0)
const duration = outputDuration > 0 ? outputDuration : 0
@@ -703,6 +703,9 @@ class LipsyncJobModel(Base):
error_message = Column(Text, nullable=False, default="")
error_code = Column(String(100), nullable=False, default="")
# 精确句子时间戳(TTS 合成后由 silencedetect 计算,用于 B-roll 精确定位)
sentence_timings = Column(JSON, nullable=True) # list[{index,text,start_time,end_time}]
# 时间戳
submitted_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
+47 -20
View File
@@ -420,21 +420,44 @@ def _escape_drawtext_text(text: str) -> str:
return result
def _resolve_font_path(font_name: str) -> str:
# 粗体字体文件映射:服务器镜像只保留了 NotoSansSC-VF.ttf(可变字体,已删除
# NotoSansCJK-Bold.ttc 以避免 Mono 变体问题,见 worker-base.Dockerfile),
# 因此无法通过 fontfile 切换到 Bold 字重。这里保留路径列表作为未来扩展,
# 实际加粗通过 borderw 黑色描边实现(见下)。
DRAWTEXT_BOLD_FONT_SEARCH_PATHS: list[str] = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/truetype/noto/NotoSansSC-Bold.ttf",
"/usr/share/fonts/noto/NotoSansSC-Bold.ttf",
]
def _resolve_font_path(font_name: str, bold: bool = False) -> str:
"""解析字体名到服务器实际字体文件路径。
查找策略:
1. 通过 DRAWTEXT_FONT_MAP 映射前端字体名到服务器关键字
2. 在 DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径
3. 未找到则返回空字符串(drawtext 使用内置默认字体)
2. bold=True 时优先查找粗体变体;找不到回退常规字重
3. 在 DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径
4. 未找到则返回空字符串(drawtext 使用内置默认字体)
"""
keyword = DRAWTEXT_FONT_MAP.get(font_name, font_name)
import os
if bold:
for path in DRAWTEXT_BOLD_FONT_SEARCH_PATHS:
if keyword.lower() in path.lower() and os.path.isfile(path):
return path
# 粗体文件找不到时,再查常规字重(后面会用描边兜底加粗)
for path in DRAWTEXT_FONT_SEARCH_PATHS:
if keyword.lower() in path.lower() and os.path.isfile(path):
return path
# fallback:遍历搜索任意可用字体
if bold:
for path in DRAWTEXT_BOLD_FONT_SEARCH_PATHS:
if os.path.isfile(path):
return path
for path in DRAWTEXT_FONT_SEARCH_PATHS:
if os.path.isfile(path):
return path
@@ -481,13 +504,13 @@ def build_title_drawtext_filter(
# ── 样式参数 ──
font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体"
font_size = int(title_config.get("font_size") or title_config.get("size") or 36)
font_size = int(title_config.get("font_size") or title_config.get("size") or 48)
font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff"
# 去掉 # 前缀(drawtext 用纯 hex 或颜色名)
if font_color.startswith("#"):
font_color = font_color[1:]
position = title_config.get("position", "top")
position = title_config.get("position") or "bottom"
bold = bool(title_config.get("bold", True))
stroke = title_config.get("stroke")
shadow = title_config.get("shadow")
@@ -495,8 +518,8 @@ def build_title_drawtext_filter(
# ── 构建 drawtext 参数 ──
params: list[str] = []
# 字体文件
font_path = _resolve_font_path(font_name)
# 字体文件:粗体优先使用 Bold 字体文件,避免同色描边造成字形偏移/重影
font_path = _resolve_font_path(font_name, bold=bold)
if font_path:
escaped_path = font_path.replace("\\", "\\\\").replace(":", "\\\\:").replace("'", "\\\\'")
params.append(f"fontfile='{escaped_path}'")
@@ -508,13 +531,11 @@ def build_title_drawtext_filter(
params.append(f"fontsize={font_size}")
params.append(f"fontcolor={font_color}")
# 粗体:drawtext 没有独立的 bold 参数,通过加大 borderw 模拟视觉粗体效果。
# 注意:不能使用 `font=bold`——FFmpeg drawtext 的 font 参数需要 fontconfig 能解析的
# 字体族名,而 "bold" 不是合法族名,会导致整个 filter_complex 解析失败(exit code 234)。
# 当用户未显式配置描边宽度时,bold 模式自动将 borderw 提升到 3 以模拟粗体。
# 描边(borderw 需要 libfreetype 支持)
# 粗体无显式描边时,自动用 borderw=3 + 近色描边模拟粗体;显式 stroke 按用户配置走
# 之前用 borderw=3 + font_color 同色描边模拟粗体,会在小字号/竖屏视频上造成
# 字形偏移、边缘重影,看起来像文字被打印了两次(用户截图中的标题"曝光曝光…")。
# 修复:粗体改用黑色细描边(borderw=2, 黑色),视觉上清晰加粗且不产生偏移。
# 用户显式开启 stroke 时按用户配置走;粗体+无stroke 默认黑色细描边。
border_width = 0
border_color = "000000"
if stroke:
@@ -526,9 +547,9 @@ def build_title_drawtext_filter(
border_width = int(stroke.get("width", 2))
border_color = (stroke.get("color") or "#000000").lstrip("#")
elif bold:
# 粗体模式且未配描边:加大描边宽度模拟粗体效果
border_width = 3
border_color = font_color # 用字体同色描边,视觉上加粗字形而非黑边
# 粗体模式且未配描边:黑色细描边,模拟粗体同时保证不重影
border_width = 2
border_color = "000000"
if border_width > 0:
params.append(f"borderw={border_width}")
params.append(f"bordercolor={border_color}")
@@ -556,8 +577,13 @@ def build_title_drawtext_filter(
and not isinstance(pos_x, bool)
and not isinstance(pos_y, bool)
):
params.append(f"x={int(pos_x)}")
params.append(f"y={int(pos_y)}")
# pos_x/pos_y 为百分比坐标(0-100),转换为 drawtext 表达式
# 例如 pos_x=50 → x=(w-text_w)*0.50(水平居中偏50%
# pos_y=30 → y=(h-text_h)*0.30
pct_x = max(0.0, min(100.0, float(pos_x))) / 100.0
pct_y = max(0.0, min(100.0, float(pos_y))) / 100.0
params.append(f"x=(w-text_w)*{pct_x:.4f}")
params.append(f"y=(h-text_h)*{pct_y:.4f}")
else:
# 三档预设位置:top / center / bottom
# x 始终水平居中:(w-text_w)/2
@@ -695,7 +721,9 @@ def _build_fullscreen_filters(
for idx, seg in enumerate(sorted_fs_segments):
start = seg.get("start_time", 0)
# 每段 B-roll 之前是否有主视频片段?
has_main_before = (idx == 0 and start > 0) or (idx > 0 and sorted_fs_segments[idx - 1].get("end_time", 0) < start)
has_main_before = (idx == 0 and start > 0) or (
idx > 0 and sorted_fs_segments[idx - 1].get("end_time", 0) < start
)
if has_main_before:
segment_labels.append(f"[main{idx}]")
segment_labels.append(f"[br{idx}]")
@@ -765,7 +793,6 @@ def _build_pip_filters(
return "".join(parts), cur_label or "vout"
def build_cover_extract_command(
cover_config: dict[str, Any],
output_path: str,
+117
View File
@@ -318,3 +318,120 @@ class TestBrollOverlayFilter:
"/tmp/cover.jpg",
)
assert "scale=" in cmd
def _make_mock_auth_user(user_id="user-1"):
"""构造 AuthenticatedUsercurrent_user.user.id."""
auth = MagicMock()
auth.user.id = user_id
return auth
class TestRenderSmartCoverRoute:
"""POST /renders/{job_id}/smart-cover — 从成片智能抽封面(步骤②)."""
def test_smart_cover_job_not_found_returns_404(self):
"""渲染任务不存在 → 404."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
from fastapi import HTTPException
mock_service = MagicMock()
mock_service.get_render_job.return_value = None
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
# 函数内部 `from app.services.ai_avatar_render_service import AiAvatarRenderService`
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
generate_render_smart_cover(job_id="render-missing", current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 404
assert "不存在" in exc_info.value.detail
mock_service.get_render_job.assert_called_once_with("render-missing", "user-1")
def test_smart_cover_job_not_completed_returns_400(self):
"""任务未 completed(如 processing)→ 400."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
from fastapi import HTTPException
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="processing", output_video_url="https://oss/video.mp4")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 400
assert "先完成视频生成" in exc_info.value.detail
def test_smart_cover_empty_video_url_returns_400(self):
"""已 completed 但 output_video_url 为空/空白 → 400."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
from fastapi import HTTPException
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="completed", output_video_url=" ")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 400
assert "URL 为空" in exc_info.value.detail
def test_smart_cover_success_updates_db_and_returns_url(self):
"""抽帧成功 → 更新 job.cover_config / output_cover_url 并 commit,返回 completed."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
mock_service = MagicMock()
mock_job = _make_mock_render_job(
status="completed",
output_video_url="https://oss/final.mp4",
)
mock_job.cover_config = {"mode": "manual"}
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch(
"app.api.routes.ai_avatar_render.generate_smart_cover", return_value="https://oss/cover.jpg"
) as mock_gen,
):
result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
mock_gen.assert_called_once_with("https://oss/final.mp4", job_id="render-1", max_frames=5)
assert result.status == "completed"
assert result.cover_url == "https://oss/cover.jpg"
assert mock_job.output_cover_url == "https://oss/cover.jpg"
assert mock_job.cover_config["mode"] == "auto_frame"
assert mock_job.cover_config["url"] == "https://oss/cover.jpg"
mock_db.commit.assert_called_once()
def test_smart_cover_extract_failure_returns_fallback_failed(self):
"""generate_smart_cover 抛异常 → fallback_failed,不抛错不写 DB."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="completed", output_video_url="https://oss/final.mp4")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch("app.api.routes.ai_avatar_render.generate_smart_cover", side_effect=RuntimeError("mediakit down")),
):
result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
assert result.status == "fallback_failed"
assert result.cover_url == ""
# 失败时不写 cover_config / 不 commit
mock_db.commit.assert_not_called()
@@ -628,3 +628,58 @@ class TestAiAvatarRenderService:
err = AiAvatarRenderError("测试错误", code="TestCode")
assert err.code == "TestCode"
assert str(err) == "测试错误"
class TestAiAvatarRenderCoverPassthrough:
"""execute_render 中封面透传逻辑(320~329 行):cover_config 含 url/imageUrl/cover_url 时直接透传到 output_cover_url."""
def _run_execute(self, mock_job, mock_lipsync_job):
"""驱动 execute_render 跑到完成阶段的通用脚手架(mock IO 部分)."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
# query.filter 返回同一个 filter 两次(render_job 查询、lipsync 查询)
mock_filter.first.side_effect = [mock_job, mock_lipsync_job]
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with (
patch.object(svc, "_download_video", return_value="/tmp/video.mp4"),
patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"),
patch("subprocess.run") as mock_run,
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
patch("app.services.ai_avatar_cover_service.generate_smart_cover", return_value=""),
patch("packages.domain.generated_video.GeneratedVideo.create", return_value=MagicMock()),
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as repo_cls,
):
import subprocess as _sp
mock_run.return_value = _sp.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
import tempfile as _tf
tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir")
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
repo_cls.return_value = MagicMock()
svc.execute_render(mock_job.id)
return mock_db, mock_job
def test_cover_url_in_cover_config_passthrough_to_output_cover(self):
"""cover_config.url 存在 → 透传到 output_cover_url."""
mock_job = _make_mock_render_job(job_id="render-cov-1", status="pending")
mock_job.cover_config = {"mode": "upload", "url": "https://oss/user-cover.jpg"}
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0)
_, job = self._run_execute(mock_job, mock_lipsync_job)
assert job.output_cover_url == "https://oss/user-cover.jpg"
def test_cover_imageurl_fallback_also_passthrough(self):
"""cover_config.imageUrl(老字段)存在 → 也透传到 output_cover_url."""
mock_job = _make_mock_render_job(job_id="render-cov-2", status="pending")
mock_job.cover_config = {"mode": "upload", "imageUrl": "https://oss/user-cover2.jpg"}
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0)
_, job = self._run_execute(mock_job, mock_lipsync_job)
assert job.output_cover_url == "https://oss/user-cover2.jpg"
@@ -288,3 +288,47 @@ class TestCancelJobTtsProcessing:
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
class TestCreateJobCommitOrder:
"""验证事务顺序修复:create_job 必须先 commit 再发 Celery 任务,避免 worker 消费时 job 不可见。"""
def test_commit_called_before_apply_async_in_tts_mode(self):
"""TTS 模式:db.commit() 必须在 apply_async() 之前调用,防止 worker 查不到 job 永远卡在 tts_processing。"""
svc, client, cosy = _make_service_with_mocks()
call_order: list[str] = []
def track_commit():
call_order.append("commit")
def track_apply_async(*args, **kwargs):
call_order.append("apply_async")
svc.db.commit.side_effect = track_commit
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async = MagicMock(side_effect=track_apply_async)
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="v-1",
script_text="测试",
)
# 至少有一次 commit 在 apply_async 之前
assert "commit" in call_order, "db.commit 必须被调用"
assert "apply_async" in call_order, "apply_async 必须被调用"
assert call_order.index("commit") < call_order.index(
"apply_async"
), f"事务顺序错误:commit 必须在 apply_async 之前,实际顺序 {call_order}"
def test_job_not_found_retry_mechanism_exists(self):
"""worker 侧 job not found 必须有重试机制(self.retry),而不是静默 return。"""
import inspect
from app.tasks.lipsync_tts import tts_synthesize_and_submit
source = inspect.getsource(tts_synthesize_and_submit.run)
assert (
"self.retry" in source or "retry" in source
), "tts_synthesize_and_submit 在 job not found 时必须重试,防止静默失败"
+243 -1
View File
@@ -185,7 +185,10 @@ class TestTtsSynthesizeAndSubmit:
mk_client.submit_lipsync.assert_called_once()
call_kwargs = mk_client.submit_lipsync.call_args.kwargs
assert call_kwargs["client_token"] == "job-1"
assert call_kwargs["audio_url"].endswith("?signed")
# CosyVoice 临时 URL 经 _sign_media_url 透传(mock 统一追加 ?signed),
# 自家 OSS 才会被重签,外部 URL 原样透传;job.audio_url 存原始临时 URL
assert call_kwargs["audio_url"] == "https://tts/raw.mp3?signed"
assert job.audio_url == "https://tts/raw.mp3"
session.commit.assert_called()
session.close.assert_called_once()
@@ -387,3 +390,242 @@ class TestSignMediaUrl:
assert result == "https://anything.example.com/a.mp3"
fake_storage.get_download_url.assert_not_called()
class TestPersistOutputVideoTask:
"""persist_output_video_task:下载 MediaKit 临时视频 → 上传自有 OSS → 更新 DB."""
def _make_persist_job(self, **kwargs):
job = MagicMock()
job.id = kwargs.get("job_id", "job-1")
job.user_id = kwargs.get("user_id", "user-1")
job.output_video_url = kwargs.get("output_video_url", "https://temp.mk/output.mp4")
job.updated_at = None
return job
def _persist_patches(self, *, job, video_bytes=b"FAKEMP4", download_side_effect=None, upload_url=None):
"""统一 patchSessionLocal、httpx.Client、storage、_sign_media_url."""
fake_app_db = ModuleType("app.db")
fake_worker_db = ModuleType("worker_app.db")
session, factory = _build_session(job)
fake_app_db.SessionLocal = factory
fake_worker_db.SessionLocal = factory
# httpx.Client 上下文管理器
fake_response = MagicMock()
fake_response.content = video_bytes
fake_response.raise_for_status = MagicMock()
fake_client = MagicMock()
fake_client.get.return_value = fake_response
fake_client_cm = MagicMock()
fake_client_cm.__enter__ = MagicMock(return_value=fake_client)
fake_client_cm.__exit__ = MagicMock(return_value=False)
FakeHttpxClient = MagicMock(return_value=fake_client_cm)
if download_side_effect is not None:
fake_client.get.side_effect = download_side_effect
# storage
storage = MagicMock()
storage.public_url = "https://oss.example.com/"
storage.upload_file.return_value = upload_url or "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
fake_httpx = ModuleType("httpx")
fake_httpx.Client = FakeHttpxClient
patches = [
patch.dict(
sys.modules,
{"app.db": fake_app_db, "worker_app.db": fake_worker_db, "httpx": fake_httpx},
),
patch("packages.shared.storage.get_shared_storage_service", return_value=storage),
patch("app.tasks.lipsync_tts._sign_media_url", side_effect=lambda url: url + "?signed" if url else url),
]
return session, fake_client, storage, patches
def test_success_download_upload_updates_db(self):
"""正常路径:下载 temp_url → 上传 OSS → 签名 → 写回 DB commit."""
from app.tasks.lipsync_tts import persist_output_video_task
job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4")
session, fake_client, storage, patches = self._persist_patches(
job=job, video_bytes=b"VIDEODATA", upload_url="https://oss.example.com/lipsync-outputs/u1/j1.mp4"
)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
fake_client.get.assert_called_once_with("https://temp.mk/x.mp4")
storage.upload_file.assert_called_once()
# 上传的 key 必须是 lipsync-outputs/{user_id}/{job_id}.mp4
key_arg = (
storage.upload_file.call_args.args[1]
if storage.upload_file.call_args.args
else storage.upload_file.call_args.kwargs.get("key")
)
# upload_file(data, key, content_type=...)
call_args = storage.upload_file.call_args.args
assert call_args[1] == "lipsync-outputs/user-1/job-1.mp4"
# output_video_url 被替换为签名后的永久 URL
assert job.output_video_url == "https://oss.example.com/lipsync-outputs/u1/j1.mp4?signed"
assert job.updated_at is not None
session.commit.assert_called_once()
session.close.assert_called_once()
def test_download_failure_keeps_temp_url_no_commit(self):
"""下载失败(raise)→ 记录 warning、保留 temp_url、不抛异常."""
from app.tasks.lipsync_tts import persist_output_video_task
job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4")
session, fake_client, storage, patches = self._persist_patches(
job=job, download_side_effect=RuntimeError("network down")
)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
storage.upload_file.assert_not_called()
# output_video_url 保持原值(temp_url
assert job.output_video_url == "https://temp.mk/x.mp4"
# 内层 except 不会 commit
# 注:若内部发生 commit 说明测试失败
session.close.assert_called_once()
def test_empty_temp_url_skips_persist(self):
"""temp_url 为空 → 直接返回,不下载不上传."""
from app.tasks.lipsync_tts import persist_output_video_task
job = self._make_persist_job(output_video_url="")
session, fake_client, storage, patches = self._persist_patches(job=job)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("job-1", "user-1", "")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
fake_client.get.assert_not_called()
storage.upload_file.assert_not_called()
session.commit.assert_not_called()
session.close.assert_called_once()
def test_job_not_found_returns_early(self):
"""DB 中找不到 job → 直接返回,不抛错."""
from app.tasks.lipsync_tts import persist_output_video_task
session, fake_client, storage, patches = self._persist_patches(job=None)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("missing", "user-1", "https://temp.mk/x.mp4")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
fake_client.get.assert_not_called()
storage.upload_file.assert_not_called()
session.commit.assert_not_called()
session.close.assert_called_once()
class TestLipsyncServiceRefreshCompletedAsyncPersist:
"""refresh_job_status 在 completed 分支异步转存的单元测试(补 0% 覆盖的 316~335 行)."""
def test_refresh_completed_dispatches_persist_task(self):
"""completed 分支:设置 temp_url → commit → dispatch persist_output_video_task.apply_async."""
from app.services.lipsync_service import LipsyncService
mock_job = MagicMock()
mock_job.id = "job-1"
mock_job.user_id = "user-1"
mock_job.mediakit_task_id = "mk-1"
mock_job.status = "submitted"
mock_job.output_video_url = ""
mock_job.output_duration = 0.0
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
mock_client = MagicMock()
mock_client.get_task_status.return_value = {
"status": "completed",
"result": {"video_url": "https://temp.mk/out.mp4", "duration": 25.5},
}
fake_persist_task = MagicMock()
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
with patch.dict("sys.modules", {}):
# 直接 patch 懒 import 路径
with patch("app.tasks.lipsync_tts.persist_output_video_task", fake_persist_task, create=False):
# 但懒 import 发生在函数内部 from app.tasks.lipsync_tts import persist_output_video_task
# 通过 patch sys.modules 的方式提供
import sys as _sys
fake_mod = MagicMock()
fake_mod.persist_output_video_task = fake_persist_task
_sys.modules["app.tasks.lipsync_tts"] = fake_mod
try:
result = svc.refresh_job_status("job-1", "user-1")
finally:
_sys.modules.pop("app.tasks.lipsync_tts", None)
assert result.status == "completed"
assert result.output_video_url == "https://temp.mk/out.mp4"
assert result.output_duration == 25.5
mock_db.commit.assert_called()
# 必须在 commit 之后 dispatch
fake_persist_task.apply_async.assert_called_once()
kwargs = fake_persist_task.apply_async.call_args.kwargs
assert kwargs["args"] == ("job-1", "user-1", "https://temp.mk/out.mp4")
def test_refresh_completed_dispatch_exception_does_not_break_return(self):
"""apply_async 抛异常(如 Celery 不可用)→ 捕获 warning,仍返回 completed job."""
from app.services.lipsync_service import LipsyncService
mock_job = MagicMock()
mock_job.id = "job-2"
mock_job.user_id = "user-1"
mock_job.mediakit_task_id = "mk-2"
mock_job.status = "submitted"
mock_job.output_video_url = ""
mock_job.output_duration = 0.0
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
mock_client = MagicMock()
mock_client.get_task_status.return_value = {
"status": "completed",
"result": {"video_url": "https://temp.mk/out2.mp4", "duration": 10.0},
}
fake_persist_task = MagicMock()
fake_persist_task.apply_async.side_effect = ConnectionError("celery down")
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
import sys as _sys
fake_mod = MagicMock()
fake_mod.persist_output_video_task = fake_persist_task
_sys.modules["app.tasks.lipsync_tts"] = fake_mod
try:
result = svc.refresh_job_status("job-2", "user-1")
finally:
_sys.modules.pop("app.tasks.lipsync_tts", None)
# 即便 dispatch 失败,主流程不受影响:仍然返回 completed + temp_url
assert result.status == "completed"
assert result.output_video_url == "https://temp.mk/out2.mp4"
fake_persist_task.apply_async.assert_called_once()
+171
View File
@@ -0,0 +1,171 @@
"""Tests for sentence timing functions in lipsync_tts."""
import os
import subprocess
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from apps.api.app.tasks.lipsync_tts import (
_compute_sentence_timings,
_estimate_sentence_timings_by_chars,
_split_script_into_sentences,
)
class TestSplitScriptIntoSentences(unittest.TestCase):
"""Tests for _split_script_into_sentences."""
def test_empty_string(self):
self.assertEqual(_split_script_into_sentences(""), [])
def test_none(self):
self.assertEqual(_split_script_into_sentences(None), [])
def test_whitespace_only(self):
self.assertEqual(_split_script_into_sentences(" \n "), [])
def test_single_sentence(self):
self.assertEqual(_split_script_into_sentences("你好世界。"), ["你好世界"])
def test_multiple_sentences_chinese(self):
result = _split_script_into_sentences("第一句。第二句!第三句?")
self.assertEqual(result, ["第一句", "第二句", "第三句"])
def test_english_punctuation(self):
result = _split_script_into_sentences("Hello World! How are you?")
self.assertEqual(result, ["Hello World", "How are you"])
def test_semicolons(self):
result = _split_script_into_sentences("第一部分;第二部分;第三部分")
self.assertEqual(result, ["第一部分", "第二部分", "第三部分"])
def test_newlines(self):
result = _split_script_into_sentences("第一行\n第二行\n第三行")
self.assertEqual(result, ["第一行", "第二行", "第三行"])
def test_no_trailing_punctuation(self):
result = _split_script_into_sentences("没有标点的句子")
self.assertEqual(result, ["没有标点的句子"])
class TestEstimateSentenceTimingsByChars(unittest.TestCase):
"""Tests for _estimate_sentence_timings_by_chars."""
def test_empty_sentences(self):
self.assertEqual(_estimate_sentence_timings_by_chars([], 10.0), [])
def test_zero_duration(self):
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], 0), [])
def test_negative_duration(self):
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], -5.0), [])
def test_single_sentence(self):
result = _estimate_sentence_timings_by_chars(["hello"], 10.0)
self.assertEqual(len(result), 1)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 10.0)
def test_two_equal_sentences(self):
result = _estimate_sentence_timings_by_chars(["你好", "世界"], 10.0)
self.assertEqual(len(result), 2)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 5.0)
self.assertAlmostEqual(result[1]["start_time"], 5.0)
self.assertAlmostEqual(result[1]["end_time"], 10.0)
def test_unequal_char_distribution(self):
result = _estimate_sentence_timings_by_chars(["ABCD", "EF"], 9.0)
self.assertEqual(len(result), 2)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 6.0) # 4/6 * 9 = 6
self.assertAlmostEqual(result[1]["start_time"], 6.0)
self.assertAlmostEqual(result[1]["end_time"], 9.0)
def test_timing_structure(self):
result = _estimate_sentence_timings_by_chars(["句子一", "句子二"], 6.0)
for item in result:
self.assertIn("index", item)
self.assertIn("text", item)
self.assertIn("start_time", item)
self.assertIn("end_time", item)
class TestComputeSentenceTimings(unittest.TestCase):
"""Tests for _compute_sentence_timings."""
def test_empty_script_returns_empty(self):
self.assertEqual(_compute_sentence_timings(b"fake_audio", "", 10.0), [])
def test_none_script_returns_empty(self):
self.assertEqual(_compute_sentence_timings(b"fake_audio", None, 10.0), [])
@patch("os.unlink")
@patch.object(tempfile, "NamedTemporaryFile")
@patch.object(subprocess, "run")
def test_silence_detection_insufficient_fallback(self, mock_run, mock_tmpfile, mock_unlink):
"""When silence detection finds too few points, fallback to char estimation."""
mock_run.return_value = MagicMock(stderr="", returncode=0)
mock_tmp = MagicMock()
mock_tmp.name = "/tmp/fake.mp3"
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
mock_tmp.__exit__ = MagicMock(return_value=False)
mock_tmpfile.return_value = mock_tmp
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
# Should fallback to char estimation with 3 sentences
self.assertEqual(len(result), 3)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
@patch("os.unlink")
@patch.object(tempfile, "NamedTemporaryFile")
@patch.object(subprocess, "run")
def test_silence_detection_with_enough_points(self, mock_run, mock_tmpfile, mock_unlink):
"""When silence detection finds enough points, use them for boundaries."""
mock_run.return_value = MagicMock(
stderr="[silencedetect] silence_end: 3.5 | silence_duration: 0.4\n"
"[silencedetect] silence_end: 7.0 | silence_duration: 0.3\n",
returncode=0,
)
mock_tmp = MagicMock()
mock_tmp.name = "/tmp/fake.mp3"
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
mock_tmp.__exit__ = MagicMock(return_value=False)
mock_tmpfile.return_value = mock_tmp
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
self.assertEqual(len(result), 3)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 3.5)
self.assertAlmostEqual(result[1]["start_time"], 3.5)
self.assertAlmostEqual(result[1]["end_time"], 7.0)
self.assertAlmostEqual(result[2]["start_time"], 7.0)
self.assertAlmostEqual(result[2]["end_time"], 10.0)
@patch("os.unlink")
@patch.object(tempfile, "NamedTemporaryFile")
@patch.object(subprocess, "run")
def test_ffmpeg_exception_fallback(self, mock_run, mock_tmpfile, mock_unlink):
"""When ffmpeg raises an exception, fallback to char estimation."""
mock_run.side_effect = Exception("ffmpeg not found")
mock_tmp = MagicMock()
mock_tmp.name = "/tmp/fake.mp3"
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
mock_tmp.__exit__ = MagicMock(return_value=False)
mock_tmpfile.return_value = mock_tmp
result = _compute_sentence_timings(b"fake_audio", "句子一。句子二。", 6.0)
# Should fallback to char estimation
self.assertEqual(len(result), 2)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 3.0)
self.assertAlmostEqual(result[1]["start_time"], 3.0)
self.assertAlmostEqual(result[1]["end_time"], 6.0)
if __name__ == "__main__":
unittest.main()
+37 -3
View File
@@ -1039,6 +1039,29 @@ class TestDrawtextBoldFalse(unittest.TestCase):
# 粗体应通过 borderw 实现
self.assertIn("borderw=", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_bold_default_uses_black_stroke_when_no_bold_font(self, mock_font):
"""默认 bold=true 且无 Bold 字体文件时,使用黑色细描边(borderw=2 + 黑),
不得使用与文字同色的 borderw>=3(否则会造成竖屏小字号重影)。"""
mock_font.return_value = "" # 无粗体字体
result = build_title_drawtext_filter({"text": "标题"})
self.assertIsNotNone(result)
self.assertIn("borderw=2", result)
# 黑描边:要么是 black 关键字,要么是 000000
self.assertTrue("bordercolor=black" in result or "bordercolor=000000" in result)
self.assertNotIn("borderw=3", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_bold_with_user_stroke_preserves_user_color(self, mock_font):
"""用户显式开启 stroke 时,stroke 颜色/宽度优先于默认粗体黑边。"""
mock_font.return_value = ""
result = build_title_drawtext_filter(
{"text": "标题", "bold": True, "stroke": {"width": 4, "color": "#ffffff"}}
)
self.assertIsNotNone(result)
self.assertIn("borderw=4", result)
self.assertIn("bordercolor=ffffff", result) # 去掉 # 前缀
class TestDrawtextPositionBranches(unittest.TestCase):
"""位置相关分支覆盖。"""
@@ -1065,12 +1088,23 @@ class TestDrawtextPositionBranches(unittest.TestCase):
self.assertIn("y=h-text_h-50", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_custom_with_float_coords(self, mock_font):
def test_position_custom_with_percentage_coords(self, mock_font):
"""自定义位置:百分比坐标转换为 drawtext 表达式."""
mock_font.return_value = ""
# pos_x=50, pos_y=30 → x=(w-text_w)*0.5000, y=(h-text_h)*0.3000
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 50, "pos_y": 30})
self.assertIsNotNone(result)
self.assertIn("x=(w-text_w)*0.5000", result)
self.assertIn("y=(h-text_h)*0.3000", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_custom_clamped_to_100(self, mock_font):
"""自定义位置:超过100的坐标被截断到100%."""
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3})
self.assertIsNotNone(result)
self.assertIn("x=100", result)
self.assertIn("y=200", result)
self.assertIn("x=(w-text_w)*1.0000", result)
self.assertIn("y=(h-text_h)*1.0000", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_custom_bool_coords_fallback(self, mock_font):