Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 66630befa9 style: auto-format with black + isort + prettier [skip ci-format-check]
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 if frontend-only change (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m24s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 2m21s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m35s
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 / Validate - Code Quality (pull_request) Successful in 4m49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m34s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m27s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m6s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m48s
CI/CD Pipeline / CI Gate (pull_request) Successful in 9s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 13s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 19s
2026-08-07 04:42:31 +00:00
SaaS Frontend Agent ace7e0d7d1 feat: integrate cover generation into video composition workflow
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build 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 / Check if frontend-only change (pull_request) Successful in 32s
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 / Validate - Migration (alembic) (pull_request) Successful in 58s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m15s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m39s
AI Code Review / AI Code Review (pull_request) Failing after 1m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
- 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
2026-08-07 12:40:06 +08:00
2 changed files with 263 additions and 0 deletions
@@ -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)
@@ -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