From e0bd51027d34663a3805df3e0698ad297075d594 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 17 Jul 2026 17:00:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(worker):=20render=5Fadapter=E4=BB=8Epla?= =?UTF-8?q?n.config.export=E8=AF=BB=E5=8F=96=E5=88=86=E8=BE=A8=E7=8E=87?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E9=BB=98=E8=AE=A4720p=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增_parse_resolution工具函数,解析1080x1920格式字符串 - render_plan从plan.config.export.resolution读取输出分辨率 - 无配置时默认1080x1920(与config_schemas默认值一致) - 新增2个单元测试验证分辨率传递与默认值 - 34个render_adapter测试全绿 --- .../worker/video_processing/render_adapter.py | 34 +++++++- tests/unit/test_render_adapter.py | 77 +++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index 46ca718c1..ff3939141 100755 --- a/apps/worker/video_processing/render_adapter.py +++ b/apps/worker/video_processing/render_adapter.py @@ -33,6 +33,24 @@ from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus logger = logging.getLogger(__name__) +DEFAULT_OUTPUT_WIDTH = 1080 +DEFAULT_OUTPUT_HEIGHT = 1920 + + +def _parse_resolution(resolution_str: str | None) -> tuple[int, int]: + """解析分辨率字符串,如 '1080x1920' → (1080, 1920)。解析失败返回默认值。""" + if not resolution_str or "x" not in resolution_str: + return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT + try: + w, h = resolution_str.lower().split("x", 1) + width = int(w.strip()) + height = int(h.strip()) + if width <= 0 or height <= 0: + return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT + return width, height + except (ValueError, TypeError): + return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT + # ── 数据结构 ────────────────────────────────────────────────────────────────── @@ -173,12 +191,26 @@ class RenderAdapter: # 4. 初始化 ASR 服务(用于自动字幕) asr_service = self._get_asr_service() - # 5. 执行统一渲染 + # 5. 从 plan.config.export 读取输出分辨率 + plan_config = plan.config or {} + export_config = plan_config.get("export", {}) or {} + output_width, output_height = _parse_resolution(export_config.get("resolution")) + logger.info( + "渲染输出分辨率: plan_id=%s resolution=%dx%d source=%s", + plan_id, + output_width, + output_height, + "config" if export_config.get("resolution") else "default", + ) + + # 6. 执行统一渲染 render_svc = UnifiedRenderService( plan=plan, clips=ready_clips, asset_path_map=asset_path_map, work_dir=work_dir, + output_width=output_width, + output_height=output_height, bgm_path=bgm_path, asr_service=asr_service, ) diff --git a/tests/unit/test_render_adapter.py b/tests/unit/test_render_adapter.py index 034886911..eaadc64bc 100755 --- a/tests/unit/test_render_adapter.py +++ b/tests/unit/test_render_adapter.py @@ -316,6 +316,83 @@ class TestRenderPlan: assert len(call_kwargs.kwargs["clips"]) == 2 assert len(call_kwargs.kwargs["asset_path_map"]) == 2 + @patch("video_processing.render_adapter.upload_to_oss") + @patch("video_processing.render_adapter.UnifiedRenderService") + @patch("video_processing.render_adapter.download_asset") + def test_resolution_from_config(self, mock_download, mock_render_cls, mock_upload, tmp_path): + """输出分辨率从 plan.config.export.resolution 读取并传给 UnifiedRenderService。""" + + def _fake_download(storage_key, local_path): + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(b"fake video data") + return True + + mock_download.side_effect = _fake_download + + mock_render = MagicMock() + mock_render.render.return_value = MagicMock( + output_path=tmp_path / "output.mp4", + duration=10.0, + file_size=102400, + width=1080, + height=1920, + ) + mock_render_cls.return_value = mock_render + mock_upload.return_value = "https://oss.example.com/rendered/plan_001/job_001.mp4" + + plan = FakePlan( + id="plan_001", + status="editing", + config={"export": {"resolution": "1080x1920", "fps": 30}}, + ) + clips = [_make_clip("c1", order=0, duration=5.0)] + asset_url_map = {"asset_c1.mp4": "https://test-bucket.oss.com/assets/asset_c1.mp4"} + adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map) + + result = adapter.render_plan("plan_001", job_id="job_001", work_dir=tmp_path / "work") + + assert result.success + mock_render_cls.assert_called_once() + call_kwargs = mock_render_cls.call_args + assert call_kwargs.kwargs["output_width"] == 1080 + assert call_kwargs.kwargs["output_height"] == 1920 + + @patch("video_processing.render_adapter.upload_to_oss") + @patch("video_processing.render_adapter.UnifiedRenderService") + @patch("video_processing.render_adapter.download_asset") + def test_resolution_default_when_missing(self, mock_download, mock_render_cls, mock_upload, tmp_path): + """plan.config 无 export 配置时使用默认分辨率 1080x1920。""" + + def _fake_download(storage_key, local_path): + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(b"fake video data") + return True + + mock_download.side_effect = _fake_download + + mock_render = MagicMock() + mock_render.render.return_value = MagicMock( + output_path=tmp_path / "output.mp4", + duration=10.0, + file_size=102400, + width=1080, + height=1920, + ) + mock_render_cls.return_value = mock_render + mock_upload.return_value = "https://oss.example.com/rendered/plan_001/job_001.mp4" + + plan = FakePlan(id="plan_001", status="editing", config={}) + clips = [_make_clip("c1", order=0, duration=5.0)] + asset_url_map = {"asset_c1.mp4": "https://test-bucket.oss.com/assets/asset_c1.mp4"} + adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map) + + result = adapter.render_plan("plan_001", job_id="job_001", work_dir=tmp_path / "work") + + assert result.success + call_kwargs = mock_render_cls.call_args + assert call_kwargs.kwargs["output_width"] == 1080 + assert call_kwargs.kwargs["output_height"] == 1920 + @patch("video_processing.render_adapter.download_asset") def test_progress_callback(self, mock_download, tmp_path): """进度回调被正确触发。""" -- 2.54.0 From 9b99c46d7dd63f79afd42dffecfd93c28351a3d4 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 17 Jul 2026 17:07:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(worker):=20ffmpeg=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=B6=E6=8D=95=E8=8E=B7stderr=E5=86=99?= =?UTF-8?q?=E5=85=A5error=5Fdetail=EF=BC=8C=E5=A2=9E=E5=BC=BA=E6=8E=92?= =?UTF-8?q?=E9=9A=9C=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RenderAdapterResult新增error_detail字段存完整stderr - render_adapter单独捕获CalledProcessError,把stderr写入error_message和error_detail - edit_plan_generation失败时把error_detail拼进gen_task.error_message - 新增test_ffmpeg_error_captures_stderr测试 - 35个render_adapter测试全绿 --- .../worker/video_processing/render_adapter.py | 16 +++++++++ .../worker_app/tasks/edit_plan_generation.py | 5 ++- tests/unit/test_render_adapter.py | 34 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index ff3939141..7323e94c8 100755 --- a/apps/worker/video_processing/render_adapter.py +++ b/apps/worker/video_processing/render_adapter.py @@ -16,6 +16,7 @@ from __future__ import annotations import logging +import subprocess import tempfile from dataclasses import dataclass from pathlib import Path @@ -70,6 +71,7 @@ class RenderAdapterResult: rendered_clip_ids: list[str] = None # 成功渲染的 clip id 列表 failed_clip_ids: list[str] = None # 失败的 clip id 列表 error_message: str = "" + error_detail: str = "" # 详细错误信息(如 ffmpeg stderr),用于排查 def __post_init__(self): if self.rendered_clip_ids is None: @@ -249,6 +251,20 @@ class RenderAdapter: failed_clip_ids=failed_clip_ids, ) + except subprocess.CalledProcessError as exc: + stderr_text = (exc.stderr or "").strip() + logger.error( + "[render-adapter] ffmpeg渲染失败: plan_id=%s job_id=%s exit_code=%d\nstderr:\n%s", + plan_id, + job_id, + exc.returncode, + stderr_text[:3000], + ) + return RenderAdapterResult( + success=False, + error_message=f"FFmpeg渲染失败(exit={exc.returncode}): {stderr_text[:200]}", + error_detail=stderr_text[:3000], + ) except Exception as exc: logger.exception( "[render-adapter] render failed: plan_id=%s job_id=%s engine=unified error=%s", diff --git a/apps/worker/worker_app/tasks/edit_plan_generation.py b/apps/worker/worker_app/tasks/edit_plan_generation.py index 3e5e974c1..bba9f5cdd 100755 --- a/apps/worker/worker_app/tasks/edit_plan_generation.py +++ b/apps/worker/worker_app/tasks/edit_plan_generation.py @@ -265,8 +265,11 @@ def _render_with_unified( return {"status": "error", "message": f"渲染失败: {render_err}"} if not result.success: + full_error = result.error_message or "渲染失败" + if result.error_detail: + full_error = f"{full_error}\n--- stderr ---\n{result.error_detail}" logger.error("渲染失败(unified): %s — %s", plan_id, result.error_message) - _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, result.error_message or "渲染失败") + _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, full_error) return {"status": "error", "message": result.error_message or "渲染失败"} output_path = result.output_path or Path("") diff --git a/tests/unit/test_render_adapter.py b/tests/unit/test_render_adapter.py index eaadc64bc..e5f2ef79d 100755 --- a/tests/unit/test_render_adapter.py +++ b/tests/unit/test_render_adapter.py @@ -393,6 +393,40 @@ class TestRenderPlan: assert call_kwargs.kwargs["output_width"] == 1080 assert call_kwargs.kwargs["output_height"] == 1920 + @patch("video_processing.render_adapter.UnifiedRenderService") + @patch("video_processing.render_adapter.download_asset") + def test_ffmpeg_error_captures_stderr(self, mock_download, mock_render_cls, tmp_path): + """ffmpeg CalledProcessError 时 stderr 写入 error_detail 和 error_message。""" + import subprocess + + def _fake_download(storage_key, local_path): + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(b"fake data") + return True + + mock_download.side_effect = _fake_download + + mock_render = MagicMock() + mock_render.render.side_effect = subprocess.CalledProcessError( + returncode=1, + cmd=["ffmpeg", "-i", "input.mp4", "output.mp4"], + stderr="Invalid data found when processing input\nLast message repeated 3 times", + ) + mock_render_cls.return_value = mock_render + + plan = FakePlan(id="plan_001", status="editing") + clips = [_make_clip("c1", order=0, duration=5.0)] + asset_url_map = {"asset_c1.mp4": "https://test-bucket.oss.com/assets/asset_c1.mp4"} + adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map) + + result = adapter.render_plan("plan_001", job_id="job_001", work_dir=tmp_path / "work") + + assert not result.success + assert "FFmpeg渲染失败" in result.error_message + assert "exit=1" in result.error_message + assert "Invalid data found" in result.error_detail + assert "Invalid data found" in result.error_message + @patch("video_processing.render_adapter.download_asset") def test_progress_callback(self, mock_download, tmp_path): """进度回调被正确触发。""" -- 2.54.0 From d365a0aa8fbd24845da8915ca61e0bcc59a47ccc Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 17:26:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?perf(ci):=20ci-l1=E2=86=92ci-check=20?= =?UTF-8?q?=E5=85=85=E5=88=86=E5=88=A9=E7=94=A8=E5=85=A8=E9=83=A8runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci-cd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index b7ea51473..f04476529 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -24,7 +24,7 @@ concurrency: jobs: check-frontend-only: name: Check if frontend-only change - runs-on: ci-l1 + runs-on: ci-check if: github.event_name == 'pull_request' outputs: skip_backend: ${{ steps.check.outputs.skip_backend }} @@ -60,7 +60,7 @@ jobs: needs: check-frontend-only if: always() && needs.check-frontend-only.outputs.skip_backend != 'true' name: Validate Code Quality And Tests - runs-on: ci-l1 + runs-on: ci-check timeout-minutes: 10 env: DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas @@ -412,7 +412,7 @@ jobs: ' frontend-lint: name: Frontend Lint - runs-on: ci-l1 + runs-on: ci-check timeout-minutes: 10 steps: - name: Checkout code -- 2.54.0