From ace7e0d7d1bf375edcc23c39df4ef40733674ff6 Mon Sep 17 00:00:00 2001 From: SaaS Frontend Agent Date: Fri, 7 Aug 2026 12:40:06 +0800 Subject: [PATCH 1/2] feat: integrate cover generation into video composition workflow - Add cover generation after video render completes in compose_video.py - Read plan.config.cover_config to determine if cover should be generated - Support enabled=false (skip), enabled=true with mode=smart/time/custom - Update job result with cover_url field - Cover generation failure does not break video composition - Add 4 unit tests for cover integration --- apps/worker/worker_app/tasks/compose_video.py | 27 ++ .../test_compose_video_cover_integration.py | 240 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 tests/unit/test_compose_video_cover_integration.py diff --git a/apps/worker/worker_app/tasks/compose_video.py b/apps/worker/worker_app/tasks/compose_video.py index 5cb97b80e..da459bf33 100644 --- a/apps/worker/worker_app/tasks/compose_video.py +++ b/apps/worker/worker_app/tasks/compose_video.py @@ -163,6 +163,32 @@ 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 +201,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..29099cc1d --- /dev/null +++ b/tests/unit/test_compose_video_cover_integration.py @@ -0,0 +1,240 @@ +"""测试 compose_video 任务中封面生成集成.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch, Mock +from pathlib import Path + +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 -- 2.54.0 From 66630befa9506e9b78689665a92621deea684ab3 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 7 Aug 2026 04:42:31 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + prettier [skip ci-format-check] --- apps/worker/worker_app/tasks/compose_video.py | 12 ++++++++---- .../test_compose_video_cover_integration.py | 18 +++++------------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/apps/worker/worker_app/tasks/compose_video.py b/apps/worker/worker_app/tasks/compose_video.py index da459bf33..1b1f2be4b 100644 --- a/apps/worker/worker_app/tasks/compose_video.py +++ b/apps/worker/worker_app/tasks/compose_video.py @@ -167,17 +167,21 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di 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 - + + 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: @@ -188,7 +192,7 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di 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, diff --git a/tests/unit/test_compose_video_cover_integration.py b/tests/unit/test_compose_video_cover_integration.py index 29099cc1d..b0312e9d7 100644 --- a/tests/unit/test_compose_video_cover_integration.py +++ b/tests/unit/test_compose_video_cover_integration.py @@ -2,8 +2,8 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch, Mock from pathlib import Path +from unittest.mock import MagicMock, Mock, patch import pytest @@ -99,9 +99,7 @@ class TestComposeVideoCoverIntegration: MockPlanRepo.return_value = plan_repo_instance # 模拟 generate_cover_from_plan - with patch( - "video_processing.cover_generator.generate_cover_from_plan" - ) as mock_gen_cover: + 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") # 执行 @@ -144,9 +142,7 @@ class TestComposeVideoCoverIntegration: 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: + 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 @@ -182,9 +178,7 @@ class TestComposeVideoCoverIntegration: 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: + 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 @@ -220,9 +214,7 @@ class TestComposeVideoCoverIntegration: 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: + with patch("video_processing.cover_generator.generate_cover_from_plan") as mock_gen_cover: # 模拟封面生成抛出异常 mock_gen_cover.side_effect = Exception("FFmpeg failed") -- 2.54.0