Compare commits

..

4 Commits

Author SHA1 Message Date
xiaoxia 4364bc951d feat(viral-video): 角色三视图持久化到OSS(替代临时URL) (#2286)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m25s
CI/CD Pipeline / Integration Tests (push) Successful in 4m5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m39s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m24s
CI/CD Pipeline / Validate - Style (push) Successful in 4m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / Validate - Security (push) Successful in 6m13s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 6m56s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m44s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m54s
CI/CD Pipeline / Unit Tests (push) Successful in 12m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 45s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 52s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m9s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m46s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m37s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-13 02:42:22 +08:00
xiaoxia 495506e2a1 feat(viral-video): MVP 自动生成角色三视图绕过肖像审核(50411) (#2285)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 54s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m8s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m19s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m26s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m56s
CI/CD Pipeline / Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m35s
CI/CD Pipeline / Validate - Style (push) Successful in 3m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m31s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m56s
CI/CD Pipeline / Validate - Security (push) Successful in 6m58s
AI Code Review / AI Code Review (pull_request) Successful in 7m0s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m35s
CI/CD Pipeline / Unit Tests (push) Successful in 15m46s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-13 02:14:32 +08:00
xiaoxia 749cee9838 refactor(viral-video): 删除intent_parsing死代码和旧一键流水线 (#2284)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m26s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m27s
CI/CD Pipeline / CI Gate (pull_request) Successful in 14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m41s
CI/CD Pipeline / Integration Tests (push) Successful in 4m46s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m7s
CI/CD Pipeline / Validate - Style (push) Successful in 5m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m11s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 6m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m26s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 7m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m31s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m38s
AI Code Review / AI Code Review (pull_request) Successful in 11m13s
CI/CD Pipeline / Unit Tests (push) Successful in 13m11s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-13 00:35:40 +08:00
xiaoxia 35f04f746b Merge pull request 'fix(viral-video): seed脚本 engine 获取修复' (#2283) from fix/viral-video-seed-script-engine into develop
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 57s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 48s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m25s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m29s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m49s
CI/CD Pipeline / Integration Tests (push) Successful in 5m14s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m9s
CI/CD Pipeline / Validate - Style (push) Successful in 6m5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Unit Tests (push) Successful in 13m17s
CI/CD Pipeline / Validate - Security (push) Successful in 17m18s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-10-12 21:22:44 +08:00
19 changed files with 213 additions and 583 deletions
+4 -104
View File
@@ -2,12 +2,10 @@
v1.6 三步分步流水线端点(单次 Seedance 出片版):
POST /api/v1/viral-video/analyze-images 阶段1:创建任务 + 仅做图片/视频分析,暂停在 image_analyzed
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 copy_generated
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染,直到完成
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后文案+分镜一步生成+审核,暂停在 copy_generated
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染(TTS+Seedance+上传),直到完成
旧端点(兼容保留,旧前端/一键生成模式):
POST /api/v1/viral-video/generate 一键入队,前半段跑到 wait_user_confirm
POST /api/v1/viral-video/{job_id}/confirm-intent 旧的意图确认后继续渲染
意图分析已并入脚本生成一步完成,不再有独立的 intent_parsing 阶段或 wait_user_confirm 状态。
通用:
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/copy_result 编导脚本)
@@ -30,8 +28,6 @@ from app.schemas.viral_video import (
AnalyzeStyleRequest,
AnalyzeStyleResponse,
ConfirmCopyRequest,
ConfirmIntentRequest,
CreateViralVideoRequest,
CreditsFormulaBreakdown,
EstimateCreditsRequest,
EstimateCreditsResponse,
@@ -75,7 +71,7 @@ def _build_copy_result(job) -> dict | None:
"""v1.6: 返回编导分镜脚本 CopyResult 结构(给前端/Seedance 使用)。
- 若 job.copy_result 已持久化(v1.6 worker 生成),直接返回(补 final_copy 兜底)。
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表, intent_result)拼装兼容结构。
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表)拼装兼容结构。
"""
cr = getattr(job, "copy_result", None)
if isinstance(cr, dict) and cr:
@@ -89,12 +85,9 @@ def _build_copy_result(job) -> dict | None:
# 兼容 v1.5 老数据:storyboard 是老格式 [{order,type,description,text,duration,...}]
copy_text = getattr(job, "generated_copy_text", "") or ""
sb = getattr(job, "storyboard", None) or []
intent = getattr(job, "intent_result", None) or {}
if not copy_text and not sb:
return None
title = ""
if isinstance(intent, dict):
title = intent.get("suggested_title") or intent.get("intent", "") or ""
shots = []
for seg in sb:
if isinstance(seg, dict):
@@ -155,7 +148,6 @@ def _to_response(job) -> ViralVideoJobResponse:
language=getattr(job, "language", "zh-CN") or "zh-CN",
video_ratio=getattr(job, "video_ratio", "9:16") or "9:16",
video_model=getattr(job, "video_model", "") or "",
intent_result=job.intent_result,
result_video_url=job.result_video_url,
pre_trusted_images=getattr(job, "pre_trusted_images", None) or None,
video_resolution=getattr(job, "video_resolution", "720p") or "720p",
@@ -331,57 +323,6 @@ def _settle_viral_video_credits(
# ── Endpoints ────────────────────────────────────────────────────────────
@router.post("/generate", response_model=ViralVideoJobResponse)
def create_viral_video(
request: CreateViralVideoRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""创建爆款视频任务,入队 Celery 编排器。"""
from packages.domain.viral_video import ViralVideoJob
repo = _get_job_repo(session)
# 创建领域实体
job = ViralVideoJob(
user_id=authenticated_user.user.id,
images=list(request.images),
industry=request.industry,
target_customer=request.target_customer,
persona_id=request.persona_id,
viral_structure=request.viral_structure,
marketing_purpose=request.marketing_purpose,
bgm_preference=request.bgm_preference,
duration=request.duration or 15,
user_copy_text=request.user_copy_text,
fusion_level=request.fusion_level,
reference_audio_path=request.reference_audio_path,
reference_video_url=request.reference_video_url,
style_strength=request.style_strength,
style_template_id=request.style_template_id,
voice_id=getattr(request, "voice_id", "") or "",
voice_source=getattr(request, "voice_source", "") or "",
video_ratio=getattr(request, "video_ratio", "9:16") or "9:16",
video_model=getattr(request, "video_model", "") or "",
video_resolution=getattr(request, "video_resolution", "720p") or "720p",
copy_result=None,
)
# 持久化
repo.save(job)
# 入队 Celery 任务
try:
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 任务已入队: job_id=%s user_id=%s", job.id, job.user_id)
except Exception as e:
logger.error("[爆款视频] 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/analyze-images", response_model=ViralVideoJobResponse)
def analyze_images(
request: AnalyzeImagesRequest,
@@ -766,7 +707,6 @@ def retry_viral_video_job(
job.phase_message = ""
job.heartbeat_at = None
# 重试时清空上次产物(参考 resume_from_image_analyzed 逻辑)
job.intent_result = None
job.copy_result = None
job.storyboard = None
job.generated_copy_text = ""
@@ -794,43 +734,6 @@ def retry_viral_video_job(
return _to_response(job)
@router.post("/{job_id}/confirm-intent", response_model=ViralVideoJobResponse)
def confirm_intent(
job_id: str,
request: ConfirmIntentRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""用户确认/修改 AI 生成的意图文案,恢复流水线。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status != ViralVideoStatus.WAIT_USER_CONFIRM:
raise HTTPException(status_code=409, detail="任务当前不在等待确认状态")
# 更新文案
if request.confirmed_copy:
job.user_copy_text = request.confirmed_copy
# 恢复流水线
job.resume_from_confirm()
repo.update(job)
# 从断点恢复 Celery 任务
try:
celery_app.send_task("worker.resume_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 意图确认,恢复流水线: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频] 恢复流水线失败: %s", e, exc_info=True)
job.mark_failed(f"恢复流水线失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/analyze-style", response_model=AnalyzeStyleResponse)
def analyze_style(
job_id: str,
@@ -1097,7 +1000,6 @@ _STATUS_STAGE = {
"running": "",
"image_analyzed": "image_analysis",
"copy_generated": "review",
"wait_user_confirm": "intent_parsing",
"completed": "uploading",
"failed": "",
"cancelled": "",
@@ -1108,7 +1010,6 @@ _STATUS_PROGRESS = {
"running": 5.0,
"image_analyzed": 15.0,
"copy_generated": 70.0,
"wait_user_confirm": 35.0,
"completed": 100.0,
"failed": 0.0,
"cancelled": 0.0,
@@ -1119,7 +1020,6 @@ _STATUS_MESSAGE = {
"running": "任务执行中",
"image_analyzed": "图片分析完成,等待填写营销参数",
"copy_generated": "文案与分镜已生成,等待确认文案",
"wait_user_confirm": "等待用户确认意图文案",
"completed": "视频生成完成",
"failed": "任务失败",
"cancelled": "任务已取消",
-50
View File
@@ -13,7 +13,6 @@ VALID_STYLE_STRENGTHS = ("light", "medium", "strict")
VALID_STAGES = (
"image_analysis",
"video_analysis",
"intent_parsing",
"script_generation",
"review",
"tts",
@@ -68,47 +67,6 @@ class CopyResult(BaseModel):
# -- Request Schemas --
class CreateViralVideoRequest(BaseModel):
"""旧接口:一键创建(保留兼容)。"""
images: list[str] = Field(..., min_length=1, max_length=20)
industry: str = ""
target_customer: str = ""
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
bgm_preference: str = ""
duration: int = Field(default=15, ge=5, le=30, description="视频时长(秒),5-30")
user_copy_text: str = ""
fusion_level: str = "ai_polish"
reference_audio_path: str = ""
reference_video_url: str = ""
style_strength: str = "medium"
style_template_id: str = ""
voice_id: str = ""
voice_source: str = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
video_resolution: str = "720p"
@field_validator("fusion_level")
@classmethod
def _v_fl(cls, v: str) -> str:
if v == "full_ai":
return "ai_full"
if v not in VALID_FUSION_LEVELS:
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
return v
@field_validator("style_strength")
@classmethod
def _v_ss(cls, v: str) -> str:
if v not in VALID_STYLE_STRENGTHS:
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
return v
class AnalyzeImagesRequest(BaseModel):
"""v1.5+ 阶段1:创建任务 + 图片/视频分析。"""
@@ -179,13 +137,6 @@ class ConfirmCopyRequest(BaseModel):
language: str | None = Field(default=None, description="用户选定的语言(confirm时可选)")
class ConfirmIntentRequest(BaseModel):
"""旧 confirm-intent(兼容)。"""
confirmed_copy: str = ""
adjustments: str = ""
class AnalyzeStyleRequest(BaseModel):
reference_video_url: str = Field(..., description="参考视频 URL")
style_template_id: str = ""
@@ -231,7 +182,6 @@ class ViralVideoJobResponse(BaseModel):
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
intent_result: dict | None = None
result_video_url: str = ""
pre_trusted_images: list[str] | None = None
video_resolution: str = "720p"
-16
View File
@@ -1,6 +1,5 @@
import apiClient from "@/api/client"
import type {
GenerateViralVideoRequest,
HistoryResponse,
StyleTemplate,
ViralVideoJob,
@@ -13,26 +12,11 @@ import type {
ViralVideoModelsResponse,
} from "./types"
/** 创建爆款视频任务 */
export function generateViralVideo(payload: GenerateViralVideoRequest) {
return apiClient.post<ViralVideoJob>("/viral-video/generate", payload).then((r) => r.data)
}
/** 查询单个任务 */
export function getViralVideoJob(id: string) {
return apiClient.get<ViralVideoJob>(`/viral-video/${id}`).then((r) => r.data)
}
/** 用户确认/修改 AI 理解的意图后继续 */
export function confirmViralVideoIntent(
id: string,
payload: { confirmed_copy?: string; edits?: Record<string, unknown> },
) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/confirm-intent`, payload)
.then((r) => r.data)
}
/** 重试失败任务 */
export function retryViralVideo(id: string) {
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/retry`).then((r) => r.data)
+3 -54
View File
@@ -21,23 +21,15 @@ export const VALID_RATIOS = ["9:16", "16:9", "1:1"] as const
export type VideoRatio = (typeof VALID_RATIOS)[number]
export type ViralVideoStatus =
| "pending"
| "running"
| "wait_user_confirm"
| "image_analyzed"
| "copy_generated"
| "completed"
| "failed"
| "cancelled"
"pending" | "running" | "image_analyzed" | "copy_generated" | "completed" | "failed" | "cancelled"
/**
* v1.6 后端流水线阶段。单次 Seedance 出片版:
* image_analysis → video_analysis(可选) → intent_parsing → script_generation → review → tts → rendering → uploading
* image_analysis → video_analysis(可选) → script_generation(含意图理解) → review → tts → rendering → uploading
*/
export type ViralVideoStage =
| "image_analysis"
| "video_analysis"
| "intent_parsing"
| "script_generation"
| "review"
| "tts"
@@ -47,7 +39,7 @@ export type ViralVideoStage =
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
/** 编导脚本阶段:属于「生成文案」按钮的范围 */
const COPY_STAGES = new Set<ViralVideoStage>(["intent_parsing", "script_generation", "review"])
const COPY_STAGES = new Set<ViralVideoStage>(["script_generation", "review"])
/** 视频生成阶段:属于「开始生成视频」按钮的范围(v1.6: TTS+单次Seedance+上传) */
const VIDEO_STAGES = new Set<ViralVideoStage>(["tts", "rendering", "uploading"])
@@ -161,22 +153,6 @@ export interface StyleTemplate {
tags?: string[]
}
export interface IntentResult {
intent?: string
key_messages?: string[]
tone?: string
target_emotion?: string
call_to_action?: string
suggested_title?: string
/** v1.5 旧字段兼容 */
product?: string
selling_points?: string[]
target_audience?: string
structure?: string
duration?: number
suggested_copy?: string
}
export interface ViralVideoJob {
id: string
status: ViralVideoStatus
@@ -194,8 +170,6 @@ export interface ViralVideoJob {
voice_mode?: "global" | "per_video"
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
bgm_preference?: string
intent_result?: IntentResult
intent_text?: string
/** v1.6 编导分镜脚本(核心产物) */
copy_result?: CopyResult
/** 向后兼容:= copy_result.shots */
@@ -225,31 +199,6 @@ export interface ViralVideoJob {
updated_at?: string
}
export interface GenerateViralVideoRequest {
images: string[]
reference_video_url?: string
douyin_url?: string
style_strength?: StyleStrength
style_template_id?: string
user_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
bgm_preference?: string
industry?: string
target_customer?: string
language?: string
persona_id?: string
viral_structure?: string
marketing_purpose?: string
/** 视频时长(5-30秒,默认15) */
duration?: number
video_model?: string
video_ratio?: string
/** 三步拆分:step 控制后端执行到哪一步暂停 */
step?: "analyze" | "generate_copy" | "generate_video"
}
export interface HistoryResponse {
items: ViralVideoJob[]
total: number
@@ -42,9 +42,6 @@ export function applyPollUpdate(t: TabTask, job: ViralVideoJob): TabTask {
nextStep = "step2_copy_ready"
copyError = undefined
break
case "wait_user_confirm":
nextStep = t.uiStep === "step3_generating" ? t.uiStep : "step2_copy_ready"
break
case "running":
if (isVideoStage(stage)) nextStep = "step3_generating"
else if (isCopyStage(stage)) nextStep = "step2_generating"
+1 -21
View File
@@ -1,8 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import {
generateViralVideo,
getViralVideoJob,
confirmViralVideoIntent,
retryViralVideo,
getViralVideoHistory,
getViralStyleTemplates,
@@ -76,10 +74,10 @@ describe("viral-video constants & stage helpers", () => {
})
it("isCopyStage", () => {
expect(isCopyStage("intent_parsing")).toBe(true)
expect(isCopyStage("script_generation")).toBe(true)
expect(isCopyStage("review")).toBe(true)
expect(isCopyStage("tts")).toBe(false)
expect(isCopyStage("image_analysis")).toBe(false)
})
it("isAnalysisStage is union", () => {
@@ -101,14 +99,6 @@ describe("viral-video API wrappers", () => {
mockPost.mockResolvedValue({ data: {} })
})
it("generateViralVideo", async () => {
mockPost.mockResolvedValue({ data: { id: "j1" } })
const r = generateViralVideo({ images: ["img1"] } as never)
vi.runAllTimersAsync()
expect(await r).toEqual({ id: "j1" })
expect(mockPost).toHaveBeenCalledWith("/viral-video/generate", { images: ["img1"] })
})
it("getViralVideoJob", async () => {
mockGet.mockResolvedValue({ data: { id: "j2" } })
const r = getViralVideoJob("j2")
@@ -117,16 +107,6 @@ describe("viral-video API wrappers", () => {
expect(mockGet).toHaveBeenCalledWith("/viral-video/j2")
})
it("confirmViralVideoIntent", async () => {
mockPost.mockResolvedValue({ data: { id: "j3" } })
const r = confirmViralVideoIntent("j3", { confirmed_copy: "hi" })
vi.runAllTimersAsync()
await r
expect(mockPost).toHaveBeenCalledWith("/viral-video/j3/confirm-intent", {
confirmed_copy: "hi",
})
})
it("retryViralVideo", async () => {
mockPost.mockResolvedValue({ data: { id: "j4" } })
await retryViralVideo("j4")
+178 -78
View File
@@ -5,14 +5,16 @@ V2 图片分析(10-05):火山OCR专用API + doubao-lite强约束JSON并行
流水线步骤:
1. _step_image_analysis 图片分析(V2: OCR+qwen3.8-flash并行 + qwen3.7-plus兜底)
1.5 _step_video_analysis 参考视频风格分析(可选)
2. _step_intent_parsing 用户文案意图解析
3. _step_script_generation 编导分镜脚本生成(融合原 copy_fusion+storyboard+review,输出 copy_result 结构 + voiceover_script)
4. _step_review 合规审核(本地规则 + LLM review,LLM 不可用降级放行,不再自动重写)
5. _step_tts CosyVoice 整段配音(voiceover_script → 单个 mp3 → 上传 OSS 拿公网 URL)
6. _step_render 单次 Seedance 生成(prompt=完整编导脚本,reference_audios=[TTS URL],reference_images=产品图,generate_audio=true)
7. _step_upload OSS 上传单个视频文件 + 通知 + 扣点
2. _step_script_generation 文案+分镜一步生成(VLM+LLM一次出文案+分镜,已包含意图理解、storyboard+review,输出 copy_result 结构 + voiceover_script)
3. _step_review 合规审核(本地规则 + LLM review,LLM 不可用降级放行,不再自动重写)
4. _step_tts CosyVoice 整段配音(voiceover_script → 单个 mp3 → 上传 OSS 拿公网 URL)
5. _step_render 单次 Seedance 生成(prompt=完整编导脚本,reference_audios=[TTS URL],reference_images=产品图,generate_audio=true)
6. _step_upload OSS 上传单个视频文件 + 通知 + 扣点
删除/不再使用:
- 意图分析独立步骤 _step_intent_parsing(已并入脚本生成,不再单独调 LLM、不再停在 WAIT_USER_CONFIRM)
- 旧一键流水线 run_viral_video_pipeline / resume_viral_video_pipeline(前端已走三步分离接口)
- 分镜拆分多段生成(storyboard 不再单独驱动分段生成,仅作为 copy_result.shots 存到 DB 给前端/日志参考)
- ffmpeg concat 拼接(concat_engine 保留但 viral video 主流程不再调用)
- placeholder 占位视频、分段重试降级
@@ -1265,7 +1267,7 @@ def _step_review(job: ViralVideoJob, copy_result: dict) -> dict:
"""步骤 4: 合规审核(PR1 简化版:Reviewer 直接接 voiceover 字符串,不做自动重写)。
返回结构与旧版兼容:{passed, score, details, issues}
注:rewrite 死路径(调用不存在的 copy_fusion 模板必崩)已在 PR1 删除。LLM 审核不可用
注:rewrite 死路径(调用不存在的 copy_fusion 模板必崩)已删除。LLM 审核不可用
时降级放行,Reviewer 会在 issues 里塞一条 warning 告知前端。
"""
try:
@@ -1772,6 +1774,112 @@ def _build_character_section(person_portraits, pre_trusted_images, trusted_urls)
return ""
# ── MVP 角色三视图自动生成(真人照片→Seedream 白底插画三视图→Seedance reference_image) ──
# 实验验证通过的三视图 prompt(参考 experiment/report.md)
_CHARACTER_SHEET_BASE_PROMPT = (
"Full-color cinematic digital painting character turnaround sheet on pure white background, "
"no text or watermarks. Three equal horizontal panels show THE SAME person, full body: "
"LEFT=three-quarter left view facing left, CENTER=front view facing camera, "
"RIGHT=three-quarter right view facing right. IDENTICAL person in all three views. "
"Outfit (identical all panels): same clothing and accessories as in the reference photo, "
"same hairstyle and glasses, same body type. "
"Style: rich color digital painting, painterly brushwork, cinematic film lighting, "
"detailed fabric texture, high-end film concept art quality "
"(NOT line art, NOT sketch, NOT anime, NOT black-white, NOT photorealistic photo). "
"Three figures upright at same height, hands at sides or behind back. 3:4 vertical, pure white background only."
)
_REALISTIC_SUFFIX = "真人实拍电影质感,ARRI Alexa色调,真实皮肤纹理,自然色彩,8K,浅景深"
def _generate_character_sheet(
person_image_url: str,
person_desc: str,
job_id: str,
tmpdir: Path,
*,
timeout: int = 90,
) -> str | None:
"""用 Seedream 基于真人照片生成白底插画角色三视图并上传 OSS(MVP)。
- person_image_url: 已签名的用户上传真人原图 URL(作 reference)
- person_desc: VLM 输出的人物外貌描述(拼入 prompt 辅助人物一致性)
- job_id: 任务 ID,用于生成 OSS 存储路径
- tmpdir: 渲染阶段临时目录,用于暂存 Seedream 返回的图片
- 成功返回 OSS 签名 URL(2h 有效,存盘路径 viral-video/character-sheets/{job_id}.jpg)
- 失败时静默降级,不抛异常,不阻断渲染流程
"""
try:
from packages.shared.ai_router import ai_router
img_client = ai_router.get_image_gen_client()
if img_client is None:
logger.warning("[character-sheet] 图片生成客户端不可用,跳过三视图")
return None
desc_short = (person_desc or "").strip()[:300]
prompt = _CHARACTER_SHEET_BASE_PROMPT
if desc_short:
prompt = f"Character description: {desc_short}. " + prompt
_t0 = time.time()
result = img_client.image_generation(
prompt=prompt,
reference_images=[person_image_url],
size="2K",
timeout=timeout,
)
if not result or not result.get("url"):
logger.warning(
"[character-sheet] Seedream 返回为空: %s",
getattr(img_client, "last_image_error", None),
)
return None
sheet_url = result["url"]
logger.info(
"[character-sheet] Seedream 生成完成 耗时%.1fs,开始下载并上传OSS",
time.time() - _t0,
)
# 下载 Seedream 返回的图片到 tmpdir
import httpx
local_path = tmpdir / f"character_sheet_{job_id}.jpg"
try:
with httpx.stream("GET", sheet_url, timeout=60) as resp:
resp.raise_for_status()
with open(local_path, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=65536):
if chunk:
f.write(chunk)
except Exception as _de:
logger.warning("[character-sheet] 下载Seedream图片失败: %s,降级用临时URL", _de)
return sheet_url # 下载失败时降级用Seedream临时URL,不阻断流程
# 上传到 OSS
try:
from video_processing.oss_helpers import get_signed_download_url, upload_to_oss
storage_key = f"viral-video/character-sheets/{job_id}.jpg"
upload_ok = upload_to_oss(str(local_path), storage_key)
if not upload_ok:
logger.warning("[character-sheet] OSS上传失败,降级用临时URL")
return sheet_url
signed_url = get_signed_download_url(storage_key, expires_seconds=7200)
if not signed_url:
logger.warning("[character-sheet] OSS签名URL生成失败,降级用临时URL")
return sheet_url
logger.info(
"[character-sheet] 三视图已存OSS key=%s 总耗时%.1fs",
storage_key,
time.time() - _t0,
)
return signed_url
except Exception as _ue:
logger.warning("[character-sheet] OSS上传异常(降级用临时URL): %s", _ue, exc_info=True)
return sheet_url
except Exception as e: # noqa: BLE001
logger.warning("[character-sheet] 三视图生成异常(降级用原图): %s", e, exc_info=True)
return None
def _assemble_seedance_prompt(copy_result: dict, job: ViralVideoJob) -> str:
"""把编导脚本拼成符合官方推荐格式的 prompt(按 provider 分发)。
@@ -1977,6 +2085,66 @@ def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | Non
except Exception as _te:
logger.warning("[爆款视频] 现场跑信任链异常: %s,回退原图+400降级纯t2v", _te, exc_info=True)
# ── MVP: 自动生成角色三视图并替换人像位 reference ──
# 流程:VLM 分析完成后(image_analysis 已有 has_person/portrait 描述)→
# 取第一个清晰人物照,用 Seedream i2i 生成白底电影插画风格三视图 →
# 三视图 URL 替换所有人物位 reference(作为 pre_trusted_images 传给 Seedance)→
# 失败时静默回退到上面的原 trust_chain / 原图逻辑,不阻断渲染
_character_sheet_url: str | None = None
try:
if first_image and _mcfg.get("provider", "doubao") == "doubao":
_ia = normalize_image_analysis(getattr(job, "image_analysis", None))
_person_idx, _person_descs = _collect_person_portraits(_ia.get("images", []))
if _person_descs:
# MVP: 只取第一个清晰人物
_primary_idx = _person_idx[0]
_primary_desc = _person_descs[0]
# 取该位置已签名 URL(images 列表已 _refresh_oss_signed_url 过,但需对齐 all_portrait_urls 顺序)
# all_portrait_urls 是 images 的重签版,按 images 顺序;_primary_idx 是 images 中的索引
if _primary_idx < len(images):
_primary_url = images[_primary_idx]
logger.info(
"[character-sheet] 检测到人物(idx=%d desc=%d字),开始生成三视图",
_primary_idx,
len(_primary_desc),
)
_character_sheet_url = _generate_character_sheet(
_primary_url,
_primary_desc,
job.id,
tmpdir,
timeout=120,
)
if _character_sheet_url:
# 把三视图作为 pre_trusted:所有人物位都用三视图,非人物位保持 None(用原图)
# 若之前已有预热 t2i 结果,三视图优先覆盖(实验验证插画三视图比写实 t2i 更稳、不被 50411 拦截)
_sheet_sparse: list[str | None] = [None] * _n_total
for _pi in _person_idx:
if 0 <= _pi < _n_total:
_sheet_sparse[_pi] = _character_sheet_url
# 首图如果是人物位,直接替换 first_image 为三视图
if _sheet_sparse[0]:
first_image = _character_sheet_url
# rest_images 里如果首图原本也在,替换掉(rest_images = images[1:])
if _primary_idx == 0:
pass # first_image 已替换
# 其他非首图的人物位:通过 pre_trusted 传(ai_client 会优先用 pre_trusted)
pre_trusted = _sheet_sparse
_n_trusted = len(_person_idx)
logger.info(
"[character-sheet] 三视图已替换人物 reference n_person=%d total=%d",
_n_trusted,
_n_total,
)
else:
logger.info("[character-sheet] 三视图生成失败,回退原 trust_chain/原图逻辑")
except Exception as _cse:
logger.warning("[character-sheet] 三视图替换异常(不阻断渲染): %s", _cse, exc_info=True)
# MVP: 有人物出镜时统一追加写实尾句(实验验证的有效提示,提升"插画→写实"跨域还原度)
if _character_sheet_url:
prompt = f"{prompt.rstrip()}\n\n{_REALISTIC_SUFFIX}"
# 第一次调用:带参考图/首帧/音频/参考视频(pre_trusted有值→走信任链;无值→原图;若400 ai_client内部自动降级纯t2v)
result = call_video_generation(
prompt=prompt,
@@ -2078,73 +2246,6 @@ def _wait_oss_ready(url: str, timeout_sec: int = 10) -> bool:
return False
# ── 主编排器 ────────────────────────────────────────────────────────────
@shared_task(
bind=True,
max_retries=2,
name="worker.run_viral_video_pipeline",
soft_time_limit=900, # 15min(完整流水线)
time_limit=960,
)
@with_viral_job(mark_running_on_entry=True)
def run_viral_video_pipeline(self: Task, job, repo, session) -> dict:
"""旧一键流水线(保留兼容):图片分析→风格分析→意图解析→WAIT_USER_CONFIRM。"""
job_id = job.id
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
image_analysis = _step_image_analysis(job)
job.image_analysis = image_analysis
# #2174: VLM完成后立即启动信任链t2i预热(从VLM结果提取人物描述),与文案阶段并行
if job.images:
try:
_imgs = (image_analysis or {}).get("images", []) or []
_start_trust_chain_preheat(job.id, _imgs)
except Exception as _e:
logger.warning("[爆款视频][阶段1] 启动信任链t2i预热失败: %s", _e)
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 15.0, "图片分析完成", {"result": image_analysis})
style_guide = None
if job.reference_video_url or job.style_template_id:
_set_stage(job, repo, session, ViralVideoStage.VIDEO_ANALYSIS, "正在分析参考视频风格...")
style_guide = _step_video_analysis(job)
job.style_guide = style_guide
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 25.0, "风格分析完成", {"style_guide": style_guide})
# v8:意图解析已并入分镜生成,旧流水线不再单独解析
intent_result = {"intent": "", "key_messages": [], "tone": "", "suggested_title": ""}
job.mark_wait_user_confirm(intent_result)
job.current_stage = ViralVideoStage.INTENT_PARSING
job.phase_message = "意图解析完成,等待用户确认"
_save_job(repo, job, session)
_emit_progress(
job_id,
ViralVideoStage.INTENT_PARSING,
35.0,
"意图解析完成,等待用户确认",
{"intent_result": intent_result, "waiting_confirm": True},
)
_emit_progress(
job_id,
ViralVideoStage.INTENT_PARSING,
35.0,
"等待用户确认意图文案",
{"intent_result": intent_result},
event_type="viral_video:wait_user",
)
return {"ok": True, "job_id": job_id, "status": "wait_user_confirm", "intent_result": intent_result}
@shared_task(bind=True, max_retries=2, name="worker.resume_viral_video_pipeline")
@with_viral_job(require_status=[ViralVideoStatus.RUNNING], start_heartbeat=False, recover_stale=False)
def resume_viral_video_pipeline(self: Task, job, repo, session) -> dict:
"""旧 confirm-intent 路径兼容:从 WAIT_USER_CONFIRM 跑完整个渲染。"""
return _run_render_pipeline(job.id, session, repo, job)
@shared_task(bind=True, max_retries=1, name="worker.run_video_style_analysis")
@with_viral_job(start_heartbeat=False, recover_stale=False)
def run_video_style_analysis(self: Task, job, repo, session) -> dict:
@@ -2270,7 +2371,7 @@ def run_viral_video_analyze(self: Task, job, repo, session) -> dict:
bind=True,
max_retries=1,
name="worker.run_viral_video_generate_copy",
soft_time_limit=600, # #2183: pro长脚本~120s+三级重试最坏300s+intent/review ~105s,给到10min
soft_time_limit=600, # #2183: pro长脚本~120s+三级重试最坏300s+review ~60s,给到10min
time_limit=660, # #2183: 11min hard limit
)
@with_viral_job(require_status=[ViralVideoStatus.RUNNING])
@@ -2512,10 +2613,9 @@ def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
_copy_src = "db"
if not isinstance(copy_result, dict) or not copy_result:
logger.error(
"[爆款视频][阶段3] copy_result 为空或无效,无法进入渲染流程。job_id=%s status=%s intent_len=%d,请重新触发「生成文案」",
"[爆款视频][阶段3] copy_result 为空或无效,无法进入渲染流程。job_id=%s status=%s,请重新触发「生成文案」",
job_id,
job.status,
len((job.intent_result or {}) if isinstance(job.intent_result, dict) else {}),
)
raise ViralVideoError(
code="copy_missing",
@@ -2545,7 +2645,7 @@ def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
)
review_result["passed"] = True
else:
# PR1: 删除自动重写死路径(原 reviewer.rewrite() 调用不存在的 copy_fusion 模板必崩);
# 自动重写死路径(reviewer.rewrite 调用不存在的 copy_fusion 模板必崩)已删除;
# error 级问题直接终止渲染,让用户修改文案后重试
logger.error(
"[爆款视频][阶段3] 合规审核未通过(%d个error),终止渲染 job_id=%s issues=%s",
@@ -56,7 +56,6 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
current_stage=getattr(model, "current_stage", "") or "",
phase_message=getattr(model, "phase_message", "") or "",
heartbeat_at=getattr(model, "heartbeat_at", None),
intent_result=dict(model.intent_result) if model.intent_result else None,
image_analysis=dict(model.image_analysis) if getattr(model, "image_analysis", None) else None,
storyboard=list(model.storyboard) if getattr(model, "storyboard", None) else None,
generated_copy_text=getattr(model, "generated_copy_text", "") or "",
@@ -110,7 +109,6 @@ class SQLAlchemyViralVideoJobRepository:
current_stage=job.current_stage or "",
phase_message=job.phase_message or "",
heartbeat_at=job.heartbeat_at,
intent_result=job.intent_result,
image_analysis=job.image_analysis,
storyboard=job.storyboard,
generated_copy_text=job.generated_copy_text,
@@ -139,7 +137,6 @@ class SQLAlchemyViralVideoJobRepository:
model.current_stage = job.current_stage or ""
model.phase_message = job.phase_message or ""
model.heartbeat_at = job.heartbeat_at
model.intent_result = job.intent_result
model.image_analysis = job.image_analysis
model.storyboard = job.storyboard
model.generated_copy_text = job.generated_copy_text or ""
@@ -199,9 +196,7 @@ class SQLAlchemyViralVideoJobRepository:
self.session.query(ViralVideoJobModel)
.filter(
ViralVideoJobModel.user_id == user_id,
ViralVideoJobModel.status.in_(
["pending", "running", "wait_user_confirm", "image_analyzed", "copy_generated"]
),
ViralVideoJobModel.status.in_(["pending", "running", "image_analyzed", "copy_generated"]),
)
.count()
)
@@ -37,26 +37,6 @@ class ImageAnalysis(BaseModel):
raw: str = ""
class CoreMessage(BaseModel):
text: str
must_keep: bool = False
confidence: float = 0.0
class PersonalBrand(BaseModel):
text: str
category: str = "brand"
class IntentResult(BaseModel):
intent_summary: str = ""
core_messages: list[CoreMessage] = Field(default_factory=list)
personal_brands: list[PersonalBrand] = Field(default_factory=list)
emotion_tone: str = ""
missing_info: list[str] = Field(default_factory=list)
raw: str = ""
class BodyPoint(BaseModel):
text: str
elaboration: str = ""
-2
View File
@@ -34,7 +34,6 @@ from packages.domain.viral_video.stages import (
PROGRESS_VIDEO_START,
STAGE_COPY_GENERATION,
STAGE_IMAGE_ANALYSIS,
STAGE_INTENT_PARSING,
STAGE_PHASE_MESSAGES,
STAGE_REVIEW,
STAGE_TRUST_CHAIN,
@@ -66,7 +65,6 @@ __all__ = [
"STAGE_IMAGE_ANALYSIS",
"STAGE_VIDEO_ANALYSIS",
"STAGE_COPY_GENERATION",
"STAGE_INTENT_PARSING",
"STAGE_REVIEW",
"STAGE_TRUST_CHAIN",
"STAGE_TTS",
+3 -25
View File
@@ -3,9 +3,10 @@
v1.6 重大简化:Seedance 2.5 单次最长30秒,单次调用直接出片,不再分段/拼接/ffmpeg concat。
状态机(三步分步):
pending -> running -> image_analyzed -> running -> copy_generated -> running -> completed
wait_user_confirm -> running -> completed (旧路径兼容)
任意阶段 fail; 任意非终态 cancel.
failed -> pending (retry 重置后重跑)。
意图解析(intent_parsing) 已并入脚本生成一步完成,不再单独作为阶段/状态存在。
"""
from __future__ import annotations
@@ -31,7 +32,6 @@ class ViralVideoStatus(StrEnum):
RUNNING = "running"
IMAGE_ANALYZED = "image_analyzed"
COPY_GENERATED = "copy_generated"
WAIT_USER_CONFIRM = "wait_user_confirm"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
@@ -40,7 +40,6 @@ class ViralVideoStatus(StrEnum):
class ViralVideoStage(StrEnum):
IMAGE_ANALYSIS = "image_analysis"
VIDEO_ANALYSIS = "video_analysis"
INTENT_PARSING = "intent_parsing"
SCRIPT_GENERATION = "script_generation" # v1.6: 编导分镜脚本(融合原 copy_fusion+storyboard+review)
REVIEW = "review"
TTS = "tts"
@@ -62,7 +61,6 @@ class StyleStrength(StrEnum):
class PromptType(StrEnum):
IMAGE_ANALYSIS = "image_analysis"
INTENT_PARSING = "intent_parsing"
SCRIPT_GENERATION = "script_generation"
REVIEW = "review"
VIDEO_STYLE_INTEGRATION = "video_style_integration"
@@ -72,7 +70,6 @@ class PromptType(StrEnum):
STAGE_LABELS = {
ViralVideoStage.IMAGE_ANALYSIS: "图片分析",
ViralVideoStage.VIDEO_ANALYSIS: "视频风格分析",
ViralVideoStage.INTENT_PARSING: "意图解析",
ViralVideoStage.SCRIPT_GENERATION: "编导脚本生成",
ViralVideoStage.REVIEW: "合规审核",
ViralVideoStage.TTS: "AI 配音",
@@ -112,7 +109,6 @@ class ViralVideoJob:
video_model: str = ""
# v1.4+ 产物
image_analysis: dict | None = None
intent_result: dict | None = None
generated_copy_text: str = "" # v1.6: 存 voiceover_script(纯口播对白),字段名兼容
storyboard: list | None = None # v1.6: 存 copy_result.shots,字段名兼容
copy_result: dict | None = None # v1.6: 完整编导脚本结构
@@ -141,7 +137,6 @@ class ViralVideoJob:
ViralVideoStatus.PENDING,
ViralVideoStatus.IMAGE_ANALYZED,
ViralVideoStatus.COPY_GENERATED,
ViralVideoStatus.WAIT_USER_CONFIRM,
ViralVideoStatus.RUNNING,
):
raise ValueError(f"Cannot transition from {self.status} to running")
@@ -184,17 +179,10 @@ class ViralVideoJob:
self.storyboard = list(shots) if isinstance(shots, list) else []
self.updated_at = datetime.now(timezone.utc)
def mark_wait_user_confirm(self, intent_result: dict) -> None:
if self.status != ViralVideoStatus.RUNNING:
raise ValueError(f"Cannot transition from {self.status} to wait_user_confirm")
self.status = ViralVideoStatus.WAIT_USER_CONFIRM
self.intent_result = intent_result
self.updated_at = datetime.now(timezone.utc)
def resume_from_image_analyzed(self, **kwargs) -> None:
"""阶段2入口:允许从 IMAGE_ANALYZED/PENDING 首次进入,也允许从 COPY_GENERATED/COMPLETED/FAILED 重新生成文案。
重新生成时清空上一轮文案产物(copy_result/intent_result/storyboard/generated_copy_text),
重新生成时清空上一轮文案产物(copy_result/storyboard/generated_copy_text),
并重置 completed_at/result_video_url/error_msg,确保前端轮询能看到新的阶段2进度。
"""
_allowed = (
@@ -216,7 +204,6 @@ class ViralVideoJob:
setattr(self, k, v)
if _is_regen:
# 清空上一轮文案/视频产物,避免前端拿到旧数据
self.intent_result = None
self.copy_result = None
self.storyboard = None
self.generated_copy_text = ""
@@ -252,15 +239,6 @@ class ViralVideoJob:
self.completed_at = None
self.credits_cost = 0.0
def resume_from_confirm(self) -> None:
if self.status != ViralVideoStatus.WAIT_USER_CONFIRM:
raise ValueError(f"Cannot resume from {self.status}")
_now = datetime.now(timezone.utc)
self.started_at = _now
self.heartbeat_at = _now
self.status = ViralVideoStatus.RUNNING
self.updated_at = _now
def mark_completed(self, video_url: str) -> None:
self.status = ViralVideoStatus.COMPLETED
self.result_video_url = video_url
-3
View File
@@ -10,7 +10,6 @@ from __future__ import annotations
STAGE_IMAGE_ANALYSIS: str = "image_analysis"
STAGE_VIDEO_ANALYSIS: str = "video_analysis"
STAGE_COPY_GENERATION: str = "script_generation" # v1.6 文案=编导分镜
STAGE_INTENT_PARSING: str = "intent_parsing"
STAGE_REVIEW: str = "review"
STAGE_TRUST_CHAIN: str = "trust_chain" # #2172 信任链预热
STAGE_TTS: str = "tts"
@@ -22,7 +21,6 @@ STAGE_PHASE_MESSAGES: dict[str, str] = {
STAGE_IMAGE_ANALYSIS: "正在分析图片内容...",
STAGE_VIDEO_ANALYSIS: "正在分析参考视频风格...",
STAGE_COPY_GENERATION: "正在生成文案与分镜...",
STAGE_INTENT_PARSING: "正在解析需求...",
STAGE_REVIEW: "正在进行出片前合规审核...",
STAGE_TRUST_CHAIN: "正在AI化人像参考...",
STAGE_TTS: "正在合成AI配音...",
@@ -49,7 +47,6 @@ __all__ = [
"STAGE_IMAGE_ANALYSIS",
"STAGE_VIDEO_ANALYSIS",
"STAGE_COPY_GENERATION",
"STAGE_INTENT_PARSING",
"STAGE_REVIEW",
"STAGE_TRUST_CHAIN",
"STAGE_TTS",
@@ -535,7 +535,6 @@ class TestViralVideoResumeForRegenerate:
# 模拟已经生成过文案和视频
job.status = ViralVideoStatus.COPY_GENERATED
job.copy_result = {"shots": [{"x": 1}], "voiceover_script": "旧文案"}
job.intent_result = {"intent": "旧意图"}
job.storyboard = [{"x": 1}]
job.generated_copy_text = "旧文案"
job.result_video_url = "http://old.mp4"
@@ -549,7 +548,6 @@ class TestViralVideoResumeForRegenerate:
assert job.status == ViralVideoStatus.RUNNING
assert job.copy_result is None
assert job.intent_result is None
assert job.storyboard is None
assert job.generated_copy_text == ""
assert job.result_video_url == ""
@@ -564,14 +562,12 @@ class TestViralVideoResumeForRegenerate:
job = ViralVideoJob(user_id="u1", images=["img1"])
job.status = ViralVideoStatus.COMPLETED
job.copy_result = {"shots": [], "voiceover_script": "xx"}
job.intent_result = {"intent": "x"}
job.result_video_url = "http://v.mp4"
job.resume_from_image_analyzed()
assert job.status == ViralVideoStatus.RUNNING
assert job.copy_result is None
assert job.intent_result is None
assert job.result_video_url == ""
def test_first_call_from_image_analyzed_keeps_fields(self):
@@ -588,14 +584,3 @@ class TestViralVideoResumeForRegenerate:
assert job.status == ViralVideoStatus.RUNNING
assert job.image_analysis == {"products": []}
assert job.industry == "美妆"
def test_wait_user_confirm_rejected(self):
"""wait_user_confirm 中间状态应被拒绝(前端正在编辑/确认文案)。"""
import pytest
from packages.domain.viral_video import ViralVideoJob, ViralVideoStatus
job = ViralVideoJob(user_id="u1", images=["img1"])
job.status = ViralVideoStatus.WAIT_USER_CONFIRM
with pytest.raises(ValueError, match="Cannot resume"):
job.resume_from_image_analyzed()
+2 -67
View File
@@ -34,7 +34,6 @@ class TestViralVideoStatus:
def test_status_values(self):
assert ViralVideoStatus.PENDING == "pending"
assert ViralVideoStatus.RUNNING == "running"
assert ViralVideoStatus.WAIT_USER_CONFIRM == "wait_user_confirm"
assert ViralVideoStatus.COMPLETED == "completed"
assert ViralVideoStatus.FAILED == "failed"
assert ViralVideoStatus.CANCELLED == "cancelled"
@@ -66,28 +65,6 @@ class TestViralVideoJobStateTransitions:
with pytest.raises(ValueError, match="Cannot transition"):
job.mark_running()
def test_mark_wait_user_confirm(self):
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
intent = {"intent": "推广", "key_messages": ["卖点1"]}
job.mark_wait_user_confirm(intent)
assert job.status == ViralVideoStatus.WAIT_USER_CONFIRM
assert job.intent_result == intent
def test_mark_wait_user_confirm_from_non_running_raises(self):
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.PENDING)
with pytest.raises(ValueError, match="Cannot transition"):
job.mark_wait_user_confirm({})
def test_resume_from_confirm(self):
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.WAIT_USER_CONFIRM)
job.resume_from_confirm()
assert job.status == ViralVideoStatus.RUNNING
def test_resume_from_non_confirm_raises(self):
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
with pytest.raises(ValueError, match="Cannot resume"):
job.resume_from_confirm()
def test_mark_completed(self):
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
job.mark_completed("https://oss.example.com/video.mp4")
@@ -140,7 +117,6 @@ class TestViralVideoStage:
expected_order = [
"image_analysis",
"video_analysis",
"intent_parsing",
"script_generation",
"review",
"tts",
@@ -157,46 +133,6 @@ class TestViralVideoStage:
class TestViralVideoSchemas:
"""Pydantic Schema 校验测试。"""
def test_create_request_valid(self):
from app.schemas.viral_video import CreateViralVideoRequest
req = CreateViralVideoRequest(images=["https://example.com/img.jpg"])
assert req.images == ["https://example.com/img.jpg"]
assert req.fusion_level == "ai_polish"
assert req.style_strength == "medium"
assert req.duration == 15
def test_create_request_empty_images_raises(self):
from app.schemas.viral_video import CreateViralVideoRequest
with pytest.raises(ValidationError):
CreateViralVideoRequest(images=[])
def test_create_request_invalid_fusion_level(self):
from app.schemas.viral_video import CreateViralVideoRequest
with pytest.raises(ValidationError):
CreateViralVideoRequest(
images=["https://example.com/img.jpg"],
fusion_level="invalid_level",
)
def test_create_request_invalid_style_strength(self):
from app.schemas.viral_video import CreateViralVideoRequest
with pytest.raises(ValidationError):
CreateViralVideoRequest(
images=["https://example.com/img.jpg"],
style_strength="ultra",
)
def test_confirm_intent_request_defaults(self):
from app.schemas.viral_video import ConfirmIntentRequest
req = ConfirmIntentRequest()
assert req.confirmed_copy == ""
assert req.adjustments == ""
def test_analyze_style_request(self):
from app.schemas.viral_video import AnalyzeStyleRequest
@@ -549,7 +485,7 @@ class TestPipelineIntegration:
):
"""v1.6: TTS整段合成 → 上传TTS到OSS → 单次 Seedance → 上传成片。"""
from apps.worker.worker_app.tasks.viral_video import (
resume_viral_video_pipeline,
run_viral_video_render,
)
job = ViralVideoJob(
@@ -557,7 +493,6 @@ class TestPipelineIntegration:
images=["https://img.com/1.jpg"],
industry="美妆",
status=ViralVideoStatus.RUNNING,
intent_result={"intent": "推广"},
duration=15,
video_ratio="9:16",
)
@@ -583,7 +518,7 @@ class TestPipelineIntegration:
mock_render.return_value = ("/tmp/video.mp4", {"completion_tokens": 1000000})
mock_upload.return_value = "https://oss.example.com/final.mp4"
result = resume_viral_video_pipeline.run("job-001")
result = run_viral_video_render.run("job-001")
assert result["ok"] is True
assert result["video_url"] == "https://oss.example.com/final.mp4"
+7 -7
View File
@@ -289,19 +289,19 @@ class TestDoubaoClientVideoGen:
# ── P0-3: resume 从 job 读 image_analysis ────────────────────────
class TestResumeReadsImageAnalysis:
def test_resume_uses_persisted_image_analysis(self):
"""resume/render pipeline 应从 job.image_analysis 读(v1.5 _run_render_pipeline 共享渲染逻辑)。"""
class TestRenderReadsImageAnalysis:
def test_render_uses_persisted_image_analysis(self):
"""render pipeline 应从 job.copy_result 读(_run_render_pipeline 共享渲染逻辑)。"""
import inspect
from apps.worker.worker_app.tasks import viral_video as vv
# v1.5 改造后 resume 委托给 _run_render_pipeline,那里读取 job.image_analysis
# render task 委托给 _run_render_pipeline,那里读取 job.copy_result
src = inspect.getsource(vv._run_render_pipeline)
assert "job.copy_result" in src
# resume 本身应该调用 _run_render_pipeline
resume_src = inspect.getsource(vv.resume_viral_video_pipeline)
assert "_run_render_pipeline" in resume_src
# run_viral_video_render 本身应该调用 _run_render_pipeline
render_src = inspect.getsource(vv.run_viral_video_render)
assert "_run_render_pipeline" in render_src
# ============ PR1: _replace_henjin_everywhere 已删除(错别字改为 prompt NEGATIVE_RULES 禁止) ============
+9 -75
View File
@@ -1,11 +1,12 @@
"""viral_video.py HTTP 端点单元测试(celery send_task 分支覆盖)。
直接调用路由函数(不启动 TestClient),通过 patch 注入 repo/session/user,
覆盖 4 个 celery_app.send_task(...) 调用点:
覆盖三步流程 celery_app.send_task(...) 调用点:
- create_viral_video (generate) -> worker.run_viral_video_pipeline
- retry_viral_video_job (retry) -> worker.run_viral_video_pipeline
- confirm_intent -> worker.resume_viral_video_pipeline
- analyze_images (analyze-images) -> worker.run_viral_video_analyze
- generate_copy (generate-copy) -> worker.run_viral_video_generate_copy
- confirm_copy (confirm-copy) -> worker.run_viral_video_render
- retry_viral_video_job (retry) -> worker.run_viral_video_analyze
- analyze_style -> worker.run_video_style_analysis
"""
@@ -61,7 +62,6 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"completed_at": None,
"stage": "",
"progress": 0.0,
"intent_result": None,
"image_analysis": None,
"storyboard": None,
"generated_copy_text": "",
@@ -86,42 +86,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
return job
# ── generate ────────────────────────────────────────────────────────────
class TestCreateViralVideo:
def _req(self, **kw):
from app.schemas.viral_video import CreateViralVideoRequest
d = {"images": ["https://x.com/a.jpg"], "industry": "电商", "target_customer": "年轻人"}
d.update(kw)
return CreateViralVideoRequest(**d)
def test_generate_dispatches_celery_task(self):
from app.api.routes import viral_video as vv_mod
req = self._req()
user = _auth_user("u1")
session = MagicMock()
saved_job = _make_job(job_id="job-new", user_id="u1", status="pending")
repo = MagicMock()
def fake_save(job):
job.id = saved_job.id
repo.save.side_effect = fake_save
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch.object(vv_mod.celery_app, "send_task") as mock_send,
):
resp = vv_mod.create_viral_video(req, authenticated_user=user, session=session)
mock_send.assert_called_once_with("worker.run_viral_video_pipeline", args=[saved_job.id])
assert resp.id == saved_job.id
# ── retry ───────────────────────────────────────────────────────────────
# ── retry ──────────────────────────────────────────────────────────────
class TestRetryViralVideo:
@@ -339,37 +304,6 @@ class TestRetryViralVideo:
assert call_args.kwargs["amount"] == 7.0
# ── confirm-intent ──────────────────────────────────────────────────────
class TestConfirmIntent:
def test_confirm_intent_dispatches_resume_task(self):
from app.api.routes import viral_video as vv_mod
from app.schemas.viral_video import ConfirmIntentRequest
from packages.domain.viral_video import ViralVideoStatus
user = _auth_user("u1")
session = MagicMock()
job = _make_job(job_id="job-cfm", user_id="u1", status=ViralVideoStatus.WAIT_USER_CONFIRM)
repo = MagicMock()
repo.get.return_value = job
req = ConfirmIntentRequest(confirmed_copy="确认后的文案")
with (
patch.object(vv_mod, "_get_job_repo", return_value=repo),
patch.object(vv_mod.celery_app, "send_task") as mock_send,
):
resp = vv_mod.confirm_intent("job-cfm", req, authenticated_user=user, session=session)
assert job.user_copy_text == "确认后的文案"
job.resume_from_confirm.assert_called_once()
mock_send.assert_called_once_with("worker.resume_viral_video_pipeline", args=["job-cfm"])
assert resp.id == "job-cfm"
# ── analyze-style ───────────────────────────────────────────────────────
class TestAnalyzeStyle:
def test_analyze_style_dispatches_analysis_task(self):
@@ -474,7 +408,7 @@ class TestGenerateCopy:
assert resp.id == "job-gc"
def test_generate_copy_rejects_wrong_status(self):
"""wait_user_confirm 等中间状态不允许调用 generate-copy(状态保护)。"""
"""cancelled 等非预期状态不允许调用 generate-copy(状态保护)。"""
import pytest
from app.api.routes import viral_video as vv_mod
from app.schemas.viral_video import GenerateCopyRequest
@@ -484,8 +418,8 @@ class TestGenerateCopy:
user = _auth_user("u1")
session = MagicMock()
# wait_user_confirm 属于前端在编辑/确认文案的中间状态,应拒绝重新触发生成
job = _make_job(job_id="job-gc2", user_id="u1", status=ViralVideoStatus.WAIT_USER_CONFIRM)
# cancelled 状态下不应触发生成文案
job = _make_job(job_id="job-gc2", user_id="u1", status=ViralVideoStatus.CANCELLED)
repo = MagicMock()
repo.get.return_value = job
-8
View File
@@ -51,7 +51,6 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"completed_at": None,
"stage": "",
"progress": 0.0,
"intent_result": None,
"image_analysis": None,
"storyboard": None,
"copy_result": {"voiceover_script": "测试口播", "shots": [{"clip_id": 1}]},
@@ -1050,13 +1049,6 @@ class TestIssue2249UserParamsFix:
assert hasattr(job, "language")
assert job.language == "zh-CN"
def test_bug5_language_in_create_request(self):
"""Bug5: CreateViralVideoRequest 接受 language"""
from apps.api.app.schemas.viral_video import CreateViralVideoRequest
req = CreateViralVideoRequest(images=["http://a.jpg"], language="en-US")
assert req.language == "en-US"
def test_bug5_language_in_generate_copy_request(self):
"""Bug5: GenerateCopyRequest 接受 language"""
from apps.api.app.schemas.viral_video import GenerateCopyRequest
-2
View File
@@ -209,7 +209,6 @@ class TestReviewWiring:
"voiceover_script": "这款口红显白又持久",
"shots": [{"scene_and_dialogue": "展示口红"}],
}
job.intent_result = {"key_messages": ["显白", "持久"], "intent": "推广"}
pass_result = ReviewResult(passed=True, score=90, issues=[], rewrite_suggestions=[])
with patch.object(Reviewer, "review", return_value=pass_result):
@@ -228,7 +227,6 @@ class TestReviewWiring:
"voiceover_script": "这款口红100%有效",
"shots": [{"scene_and_dialogue": "展示"}],
}
job.intent_result = {"key_messages": ["显白"], "intent": "推广"}
fail_result = ReviewResult(
passed=False,
+5 -27
View File
@@ -106,31 +106,11 @@ class TestWorkerEmitProgress:
assert parsed["type"] == "viral_video:failed"
assert parsed["data"]["error"] == "oom"
def test_emit_progress_wait_user_event(self):
fake_r = MagicMock()
with patch("redis.from_url", return_value=fake_r):
worker_vv._emit_progress(
"job-4",
"intent_parsing",
35,
message="waiting for you",
event_type="viral_video:wait_user",
)
_, payload = fake_r.publish.call_args.args
parsed = json.loads(payload)
assert parsed["type"] == "viral_video:wait_user"
# ---------------------------------------------------------------------------
# Pure helpers on the route module
# ---------------------------------------------------------------------------
class TestWSHelpers:
def test_estimate_progress_maps_status(self):
assert vv_module._estimate_progress(_make_job(status="pending")) == 0.0
assert vv_module._estimate_progress(_make_job(status="running")) == 5.0
assert vv_module._estimate_progress(_make_job(status="wait_user_confirm")) == 35.0
assert vv_module._estimate_progress(_make_job(status="completed")) == 100.0
assert vv_module._estimate_progress(_make_job(status="failed")) == 0.0
@@ -140,8 +120,6 @@ class TestWSHelpers:
assert isinstance(msg, str) and msg
job_failed = _make_job(status="failed", error_msg="boom")
assert "boom" in vv_module._initial_message(job_failed)
job_wait = _make_job(status="wait_user_confirm")
assert "等待" in vv_module._initial_message(job_wait)
def test_stage_from_status_falls_back(self):
assert isinstance(vv_module._stage_from_status(_make_job(status="pending")), str)
@@ -325,16 +303,16 @@ class TestWSInitialSnapshot:
# Session was used for both ownership check and initial snapshot.
assert sess.close.call_count >= 2
def test_running_job_with_enum_status(self):
def test_image_analyzed_job_sends_snapshot(self):
job = _make_job(
status=SimpleNamespace(value="wait_user_confirm"),
status=SimpleNamespace(value="image_analyzed"),
user_id="user-a",
is_terminal=False,
)
received, _, _ = _run_ws_handshake(job=job)
assert received[0]["data"]["status"] == "wait_user_confirm"
assert received[0]["progress"] == 35.0
assert "等待" in received[0]["message"]
assert received[0]["data"]["status"] == "image_analyzed"
assert received[0]["progress"] == 15.0
assert "图片分析" in received[0]["message"]
def test_already_completed_job_sends_completion_event_and_closes(self):
job = _make_job(