fix(worker): render_adapter分辨率读取 + ffmpeg错误捕获增强 (#479)
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1461h57m50s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1461h57m52s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1461h57m52s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1461h57m53s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1461h58m15s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1462h29m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #479.
This commit is contained in:
2026-07-17 17:31:26 +08:00
committed by auto-approve-bot
parent 8c30f67900
commit 2a12532fb4
3 changed files with 164 additions and 2 deletions
+49 -1
View File
@@ -16,6 +16,7 @@
from __future__ import annotations
import logging
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
@@ -33,6 +34,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
# ── 数据结构 ──────────────────────────────────────────────────────────────────
@@ -52,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:
@@ -173,12 +193,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,
)
@@ -217,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",
@@ -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("")
+111
View File
@@ -316,6 +316,117 @@ 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.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):
"""进度回调被正确触发。"""