22e04d65a7
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m45s
AI Code Review / AI Code Review (pull_request) Successful in 7m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 20m32s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 20m41s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 24m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 26m22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 31s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m18s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 1h9m19s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
爆款视频由 Seedance 2.5 直接生成人物口型,不需要 MuseTalk 事后对口型。 MuseTalk 是 AI 数字人路线(上传人物视频+配音→对嘴型)用的,跟爆款视频是两条不同路线。 - 删除 _step_musetalk 函数 - resume_pipeline 直接把 render 输出传给 upload - 更新流水线 docstring 为 9 步 - 删除/更新对应单测 - ViralVideoStage.MUSETALK 枚举值保留以避免前端 breaking change
231 lines
9.0 KiB
Python
231 lines
9.0 KiB
Python
"""#2106 P0 修复单测:Seedance 对接、image_analysis 持久化、TTS Path 统一、BGM/MuseTalk 跳过。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path as _Path
|
||
|
||
# worker 容器 PYTHONPATH 包含 apps/worker(worker 侧代码使用顶层包名 services/、viral_video/)
|
||
_WORKER_ROOT = _Path(__file__).resolve().parents[2] / "apps" / "worker"
|
||
if str(_WORKER_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(_WORKER_ROOT))
|
||
|
||
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from packages.domain.viral_video import ViralVideoJob, ViralVideoStatus
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_job():
|
||
return ViralVideoJob(
|
||
user_id="user-001",
|
||
images=["https://img.com/1.jpg"],
|
||
industry="美妆",
|
||
duration=15,
|
||
user_copy_text="测试文案",
|
||
fusion_level="ai_polish",
|
||
)
|
||
|
||
|
||
# ── P0-2: _step_video_analysis import 路径 ──────────────────────────
|
||
|
||
|
||
class TestVideoAnalysisImport:
|
||
def test_no_reference_returns_none(self, mock_job):
|
||
from apps.worker.worker_app.tasks.viral_video import _step_video_analysis
|
||
|
||
mock_job.reference_video_url = ""
|
||
assert _step_video_analysis(mock_job) is None
|
||
|
||
def test_with_reference_returns_dict_or_none(self, mock_job):
|
||
"""有参考视频 URL 时,不管分析成功/失败/占位,返回 dict(不抛异常)。"""
|
||
from apps.worker.worker_app.tasks.viral_video import _step_video_analysis
|
||
|
||
mock_job.reference_video_url = "https://example.com/ref.mp4"
|
||
result = _step_video_analysis(mock_job)
|
||
# 允许占位/失败/真实返回,但绝不能抛异常
|
||
assert result is None or isinstance(result, dict)
|
||
|
||
|
||
# ── P0-3: image_analysis 字段 ─────────────────────────────────────
|
||
|
||
|
||
class TestImageAnalysisField:
|
||
def test_default_none(self):
|
||
job = ViralVideoJob(user_id="u1")
|
||
assert job.image_analysis is None
|
||
|
||
def test_persist_and_read(self, mock_job):
|
||
mock_job.image_analysis = {"products": [{"name": "口红"}]}
|
||
assert mock_job.image_analysis["products"][0]["name"] == "口红"
|
||
|
||
|
||
# ── P0-1: storyboard 规范化 ────────────────────────────────────────
|
||
|
||
|
||
class TestStoryboardNormalize:
|
||
def test_normalize_fills_defaults(self):
|
||
from apps.worker.worker_app.tasks.viral_video import _normalize_storyboard
|
||
|
||
raw = [{"order": 0, "description": "镜头一"}]
|
||
out = _normalize_storyboard(raw, total_duration=10, n_segments=1, copy_text="文案")
|
||
assert len(out) == 1
|
||
assert out[0]["duration"] >= 3
|
||
assert out[0]["ken_burns"] in {"zoom_in", "zoom_out", "pan_left", "pan_right", "static"}
|
||
assert out[0]["type"] == "product_shot"
|
||
assert out[0]["text"] == ""
|
||
|
||
def test_normalize_scales_to_total_duration(self):
|
||
from apps.worker.worker_app.tasks.viral_video import _normalize_storyboard
|
||
|
||
raw = [
|
||
{"order": 0, "duration": 10, "description": "a"},
|
||
{"order": 1, "duration": 10, "description": "b"},
|
||
]
|
||
out = _normalize_storyboard(raw, total_duration=10, n_segments=2, copy_text="x")
|
||
total = sum(s["duration"] for s in out)
|
||
assert total == 10
|
||
|
||
def test_fallback_storyboard(self):
|
||
from apps.worker.worker_app.tasks.viral_video import _fallback_storyboard
|
||
|
||
out = _fallback_storyboard("文案", total_duration=15, n_segments=3)
|
||
assert len(out) == 3
|
||
assert sum(s["duration"] for s in out) == 15
|
||
assert all(s["duration"] >= 3 for s in out)
|
||
|
||
def test_storyboard_llm_list(self, mock_job):
|
||
from apps.worker.worker_app.tasks.viral_video import _step_storyboard
|
||
|
||
with patch("packages.shared.ai_service.call_llm") as mock_llm:
|
||
mock_llm.return_value = [
|
||
{"order": 0, "description": "产品特写", "duration": 5, "text": "t1"},
|
||
{"order": 1, "description": "使用场景", "duration": 5, "text": "t2"},
|
||
{"order": 2, "description": "CTA", "duration": 5, "text": "t3"},
|
||
]
|
||
result = _step_storyboard(mock_job, "文案", {"products": []})
|
||
assert len(result) == 3
|
||
assert all("description" in s for s in result)
|
||
|
||
|
||
# ── P1: TTS 返回 Path|None ────────────────────────────────────────
|
||
|
||
|
||
class TestTTSPath:
|
||
def test_tts_returns_none_on_import_error(self, mock_job):
|
||
"""get_tts_service 抛 ImportError 时 _step_tts 返回 None。"""
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
with patch("services.tts_service_factory.get_tts_service", side_effect=ImportError("no tts")):
|
||
assert vv._step_tts(mock_job, "文案") is None
|
||
|
||
def test_tts_returns_none_when_path_not_exists(self, mock_job, tmp_path):
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
fake_service = MagicMock()
|
||
fake_service.synthesize.return_value = str(tmp_path / "not_exist.mp3")
|
||
with patch("services.tts_service_factory.get_tts_service", return_value=fake_service):
|
||
assert vv._step_tts(mock_job, "文案") is None
|
||
|
||
def test_tts_returns_path_when_exists(self, mock_job, tmp_path):
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
audio = tmp_path / "voice.mp3"
|
||
audio.write_bytes(b"ID3fake")
|
||
fake_service = MagicMock()
|
||
fake_service.synthesize.return_value = audio
|
||
with patch("services.tts_service_factory.get_tts_service", return_value=fake_service):
|
||
result = vv._step_tts(mock_job, "文案")
|
||
assert isinstance(result, Path)
|
||
assert result.exists()
|
||
|
||
|
||
# ── P1: BGM 跳过 / MuseTalk 无 persona 跳过 ───────────────────────
|
||
|
||
|
||
class TestBGMSkip:
|
||
def test_bgm_returns_none(self, mock_job):
|
||
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
|
||
|
||
mock_job.bgm_preference = "upbeat"
|
||
assert _step_bgm_select(mock_job) is None
|
||
|
||
|
||
# ── P0-1: call_video_generation 参数构造 ──────────────────────────
|
||
|
||
|
||
class TestCallVideoGeneration:
|
||
def test_returns_none_when_client_unavailable(self):
|
||
from packages.shared.ai_service import call_video_generation
|
||
|
||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||
mock_client = MagicMock()
|
||
mock_client.is_available = False
|
||
mock_get.return_value = mock_client
|
||
assert call_video_generation("prompt") is None
|
||
|
||
def test_delegates_to_client(self, tmp_path):
|
||
from packages.shared.ai_service import call_video_generation
|
||
|
||
out = tmp_path / "v.mp4"
|
||
out.write_bytes(b"fake")
|
||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||
mock_client = MagicMock()
|
||
mock_client.is_available = True
|
||
mock_client.video_generation.return_value = str(out)
|
||
mock_get.return_value = mock_client
|
||
result = call_video_generation(prompt="测试", image_url="https://img/x.jpg", duration=5, ratio="9:16")
|
||
assert result == str(out)
|
||
mock_client.video_generation.assert_called_once()
|
||
kwargs = mock_client.video_generation.call_args.kwargs
|
||
assert kwargs["prompt"] == "测试"
|
||
assert kwargs["image_url"] == "https://img/x.jpg"
|
||
assert kwargs["duration"] == 5
|
||
|
||
|
||
# ── P0-1: _step_render 占位片段生成 ──────────────────────────────
|
||
|
||
|
||
class TestPlaceholderClip:
|
||
def test_make_placeholder_clip(self, tmp_path):
|
||
import shutil
|
||
|
||
from apps.worker.worker_app.tasks.viral_video import _make_placeholder_clip, _probe_ok
|
||
|
||
if not shutil.which("ffmpeg"):
|
||
pytest.skip("ffmpeg not available")
|
||
|
||
out = _make_placeholder_clip(tmp_path, 0, 3)
|
||
assert out.exists()
|
||
assert _probe_ok(str(out))
|
||
|
||
|
||
# ── P0-1: DoubaoClient.video_generation 在不可用时返回 None ───────
|
||
|
||
|
||
class TestDoubaoClientVideoGen:
|
||
def test_unavailable_returns_none(self):
|
||
from packages.shared.ai_client import DoubaoClient
|
||
|
||
client = DoubaoClient.__new__(DoubaoClient)
|
||
client.api_key = "" # is_available -> False
|
||
assert client.video_generation("prompt") is None
|
||
|
||
|
||
# ── P0-3: resume 从 job 读 image_analysis ────────────────────────
|
||
|
||
|
||
class TestResumeReadsImageAnalysis:
|
||
def test_resume_uses_persisted_image_analysis(self):
|
||
"""resume_pipeline 应从 job.image_analysis 读(P0-3 持久化)。"""
|
||
import inspect
|
||
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
src = inspect.getsource(vv.resume_viral_video_pipeline)
|
||
assert "job.image_analysis" in src
|