diff --git a/apps/worker/worker_app/tasks/compose_video.py b/apps/worker/worker_app/tasks/compose_video.py index 5cb97b80e..1b1f2be4b 100644 --- a/apps/worker/worker_app/tasks/compose_video.py +++ b/apps/worker/worker_app/tasks/compose_video.py @@ -163,6 +163,36 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di job_service.fail_job(job_id, error_msg[:500]) raise RuntimeError(result.error_message) + # 生成封面(如果配置启用) + cover_url = None + try: + from video_processing.cover_generator import generate_cover_from_plan + + from packages.adapters.sqlalchemy_impl.edit_plan_repository import ( + SQLAlchemyEditPlanRepository as EditPlanRepository, + ) + + # 获取 plan 对象 + plan_repo = EditPlanRepository(db) + plan = plan_repo.get(plan_id) + + if plan and result.output_path: + # 检查 cover_config + cover_config = (plan.config or {}).get("cover_config") + if cover_config and cover_config.get("enabled", False): + from pathlib import Path + + output_dir = Path(result.output_path).parent + cover_path = generate_cover_from_plan(plan, result.output_path, output_dir) + if cover_path: + # 生成 cover_url(相对路径或上传到存储) + cover_url = f"/covers/{plan_id}.jpg" + logger.info("封面生成成功: plan_id=%s cover_path=%s", plan_id, cover_path) + else: + logger.info("封面生成未启用: plan_id=%s", plan_id) + except Exception as e: + logger.warning("封面生成失败(不影响视频合成): plan_id=%s error=%s", plan_id, e) + # 更新 Job 状态为完成 result_data = { "plan_id": plan_id, @@ -175,6 +205,7 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di "width": result.width, "height": result.height, "file_size": result.file_size, + "cover_url": cover_url, } job_service.complete_job(job_id, result=result_data) diff --git a/tests/unit/test_compose_video_cover_integration.py b/tests/unit/test_compose_video_cover_integration.py new file mode 100644 index 000000000..b0312e9d7 --- /dev/null +++ b/tests/unit/test_compose_video_cover_integration.py @@ -0,0 +1,232 @@ +"""测试 compose_video 任务中封面生成集成.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + + +class TestComposeVideoCoverIntegration: + """测试视频合成任务中的封面生成集成.""" + + @pytest.fixture + def mock_job_service(self): + """模拟 JobService.""" + service = MagicMock() + service.get_job.return_value = MagicMock( + id="job_123", + payload={"plan_id": "plan_456"}, + ) + return service + + @pytest.fixture + def mock_db(self): + """模拟数据库会话.""" + return MagicMock() + + @pytest.fixture + def mock_render_result(self): + """模拟渲染结果.""" + result = MagicMock() + result.success = True + result.output_path = Path("/tmp/output/video_123.mp4") + result.output_url = "https://example.com/video_123.mp4" + result.duration = 30.0 + result.clip_count = 5 + result.width = 1080 + result.height = 1920 + result.file_size = 1024000 + return result + + @pytest.fixture + def mock_plan_with_cover_enabled(self): + """模拟启用封面的 plan.""" + plan = MagicMock() + plan.id = "plan_456" + plan.config = { + "cover_config": { + "enabled": True, + "mode": "smart", + } + } + return plan + + @pytest.fixture + def mock_plan_with_cover_disabled(self): + """模拟禁用封面的 plan.""" + plan = MagicMock() + plan.id = "plan_456" + plan.config = { + "cover_config": { + "enabled": False, + } + } + return plan + + @pytest.fixture + def mock_plan_without_cover_config(self): + """模拟没有 cover_config 的 plan.""" + plan = MagicMock() + plan.id = "plan_456" + plan.config = {} + return plan + + def test_cover_generation_called_when_enabled( + self, + mock_job_service, + mock_db, + mock_render_result, + mock_plan_with_cover_enabled, + ): + """测试封面生成在启用时被调用.""" + from worker_app.tasks.compose_video import _compose_with_unified_engine + + # 模拟 RenderAdapter + with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter: + adapter_instance = MagicMock() + adapter_instance.render_plan.return_value = mock_render_result + adapter_instance.validate_plan.return_value = (True, [], [], 5, 5) + MockAdapter.return_value = adapter_instance + + # 模拟 EditPlanRepository + with patch( + "packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository" + ) as MockPlanRepo: + plan_repo_instance = MagicMock() + plan_repo_instance.get.return_value = mock_plan_with_cover_enabled + MockPlanRepo.return_value = plan_repo_instance + + # 模拟 generate_cover_from_plan + with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover: + mock_gen_cover.return_value = Path("/tmp/output/cover_plan_456.jpg") + + # 执行 + task = MagicMock() + result = _compose_with_unified_engine( + task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db + ) + + # 验证封面生成被调用 + mock_gen_cover.assert_called_once() + call_args = mock_gen_cover.call_args + assert call_args[0][0] == mock_plan_with_cover_enabled # plan + assert call_args[0][1] == mock_render_result.output_path # video_path + assert call_args[0][2] == mock_render_result.output_path.parent # output_dir + + # 验证结果包含 cover_url + assert "cover_url" in result["result"] + assert result["result"]["cover_url"] == "/covers/plan_456.jpg" + + def test_cover_generation_skipped_when_disabled( + self, + mock_job_service, + mock_db, + mock_render_result, + mock_plan_with_cover_disabled, + ): + """测试封面生成在禁用时被跳过.""" + from worker_app.tasks.compose_video import _compose_with_unified_engine + + with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter: + adapter_instance = MagicMock() + adapter_instance.render_plan.return_value = mock_render_result + adapter_instance.validate_plan.return_value = (True, [], [], 5, 5) + MockAdapter.return_value = adapter_instance + + with patch( + "packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository" + ) as MockPlanRepo: + plan_repo_instance = MagicMock() + plan_repo_instance.get.return_value = mock_plan_with_cover_disabled + MockPlanRepo.return_value = plan_repo_instance + + with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover: + task = MagicMock() + result = _compose_with_unified_engine( + task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db + ) + + # 验证封面生成未被调用 + mock_gen_cover.assert_not_called() + + # 验证结果中 cover_url 为 None + assert "cover_url" in result["result"] + assert result["result"]["cover_url"] is None + + def test_cover_generation_skipped_when_no_config( + self, + mock_job_service, + mock_db, + mock_render_result, + mock_plan_without_cover_config, + ): + """测试没有 cover_config 时封面生成被跳过.""" + from worker_app.tasks.compose_video import _compose_with_unified_engine + + with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter: + adapter_instance = MagicMock() + adapter_instance.render_plan.return_value = mock_render_result + adapter_instance.validate_plan.return_value = (True, [], [], 5, 5) + MockAdapter.return_value = adapter_instance + + with patch( + "packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository" + ) as MockPlanRepo: + plan_repo_instance = MagicMock() + plan_repo_instance.get.return_value = mock_plan_without_cover_config + MockPlanRepo.return_value = plan_repo_instance + + with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover: + task = MagicMock() + result = _compose_with_unified_engine( + task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db + ) + + # 验证封面生成未被调用 + mock_gen_cover.assert_not_called() + + # 验证结果中 cover_url 为 None + assert "cover_url" in result["result"] + assert result["result"]["cover_url"] is None + + def test_cover_generation_failure_does_not_break_video( + self, + mock_job_service, + mock_db, + mock_render_result, + mock_plan_with_cover_enabled, + ): + """测试封面生成失败不影响视频合成.""" + from worker_app.tasks.compose_video import _compose_with_unified_engine + + with patch("video_processing.render_adapter.RenderAdapter") as MockAdapter: + adapter_instance = MagicMock() + adapter_instance.render_plan.return_value = mock_render_result + adapter_instance.validate_plan.return_value = (True, [], [], 5, 5) + MockAdapter.return_value = adapter_instance + + with patch( + "packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository" + ) as MockPlanRepo: + plan_repo_instance = MagicMock() + plan_repo_instance.get.return_value = mock_plan_with_cover_enabled + MockPlanRepo.return_value = plan_repo_instance + + with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover: + # 模拟封面生成抛出异常 + mock_gen_cover.side_effect = Exception("FFmpeg failed") + + task = MagicMock() + result = _compose_with_unified_engine( + task, mock_job_service, mock_job_service.get_job(), "plan_456", mock_db + ) + + # 验证视频合成仍然成功 + assert result["status"] == "completed" + assert "result" in result + assert result["result"]["output_url"] == mock_render_result.output_url + + # 验证结果中 cover_url 为 None + assert result["result"]["cover_url"] is None