fix(worker): render_adapter分辨率读取 + ffmpeg错误捕获增强 #479
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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):
|
||||
"""进度回调被正确触发。"""
|
||||
|
||||
Reference in New Issue
Block a user