fix(worker): render_adapter分辨率读取 + ffmpeg错误捕获增强 #479

Merged
auto-approve-bot merged 3 commits from fix/render-resolution-from-config into develop 2026-07-17 17:31:26 +08:00
4 changed files with 167 additions and 5 deletions
+3 -3
View File
@@ -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
+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):
"""进度回调被正确触发。"""