fix(P0): legacy渲染引擎5项normalize补全 + ffmpeg错误日志增强 #511

Merged
auto-approve-bot merged 3 commits from fix/p0-legacy-normalize-and-error-log into develop 2026-07-18 15:07:59 +08:00
3 changed files with 88 additions and 12 deletions
+22 -9
View File
@@ -422,7 +422,11 @@ class VideoComposeService:
# 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容)
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
# 3. fps: 统一帧率(concat 要求所有输入帧率一致)
# 3. format: 统一像素格式为 yuv420pH.264 标准格式,concat 要求所有输入像素格式一致)
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
filters.append("format=yuv420p")
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
if fps and fps > 0:
filters.append(f"fps={fps}")
@@ -623,13 +627,22 @@ def _build_xfade_filter(
# 总时长需要减去转场重叠部分
total_duration -= transition_duration * (n - 1)
# 音频 crossfade(简化处理:使用 adelay + amix
audio_labels = [c.audio_label for c in clip_chains if c.audio_label]
if len(audio_labels) >= 2:
# 简单拼接音频(不做 crossfade)
audio_inputs = "".join(f"[{label}]" for label in audio_labels)
parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]")
elif len(audio_labels) == 1:
parts.append(f"[{audio_labels[0]}]acopy[outa]")
# 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败
audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label]
if len(audio_chains_with_label) >= 2:
normalized_audio_labels: list[str] = []
for chain, _ in audio_chains_with_label:
norm_label = f"anorm_{chain.video_label}"
audio_filters = [
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
normalized_audio_labels.append(norm_label)
audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels)
parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]")
elif len(audio_chains_with_label) == 1:
parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]")
return ";".join(parts), max(0.0, total_duration)
@@ -361,14 +361,37 @@ def _render_with_legacy(
fps=fps,
)
logger.info("执行 FFmpeg (legacy): plan_id=%s", plan_id)
logger.info("执行 FFmpeg (legacy): plan_id=%s cmd=%s", plan_id, " ".join(compose_cmd.command)[:500])
try:
from video_processing.ffmpeg_utils import run_ffmpeg
run_ffmpeg(compose_cmd.command, timeout=3600)
except Exception as e:
error_msg = f"FFmpeg 执行失败: {str(e)[:500]}"
logger.error("FFmpeg 执行失败(legacy): %s%s", plan_id, error_msg)
# 提取完整 stderr(如果是 CalledProcessError
stderr_text = ""
if hasattr(e, "stderr"):
stderr_raw = e.stderr
if isinstance(stderr_raw, bytes):
stderr_text = stderr_raw.decode("utf-8", errors="replace")
elif isinstance(stderr_raw, str):
stderr_text = stderr_raw
# 完整命令(截断前2000字符,避免日志过大)
full_cmd = " ".join(compose_cmd.command)
cmd_preview = full_cmd[:2000] + ("..." if len(full_cmd) > 2000 else "")
# 拼接完整错误信息:命令 + 异常 + stderr最后1500字符
error_parts = [f"FFmpeg渲染失败(exit={getattr(e, 'returncode', 'unknown')})"]
error_parts.append("--- cmd ---")
error_parts.append(cmd_preview)
if stderr_text:
# 取最后1500字符,通常错误信息在末尾
stderr_preview = stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
error_parts.append("--- stderr (last 1500 chars) ---")
error_parts.append(stderr_preview)
error_msg = "\n".join(error_parts)
logger.error("FFmpeg 执行失败(legacy): plan_id=%s\n%s", plan_id, error_msg)
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg)
return {"status": "error", "message": error_msg}
+40
View File
@@ -359,6 +359,20 @@ class TestBuildComposeCommand(TestCase):
self.assertIn("black", filter_text)
self.assertIn("trim=", filter_text)
def test_filter_chain_contains_format_yuv420p(self):
"""滤镜链包含 format=yuv420p,统一像素格式避免 concat 失败。"""
plan = _StubPlan()
clips = [_make_ready_clip(plan_id=plan.id)]
svc = _make_service(plan, clips)
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
chain = cmd.clip_chains[0]
filter_text = ",".join(chain.filters)
# format=yuv420p 必须在 pad 之后、fps 之后(像素格式统一放在分辨率之后)
pad_idx = filter_text.index("pad=")
fmt_idx = filter_text.index("format=yuv420p")
self.assertGreater(fmt_idx, pad_idx, "format 应该在 pad 之后")
def test_filter_chain_contains_fps(self):
"""滤镜链包含 fps 滤镜,用于统一帧率避免 concat 失败。"""
plan = _StubPlan()
@@ -617,6 +631,32 @@ class TestBuildXfadeFilter(TestCase):
# 总时长 = 15 - 0.5*2 = 14.0
self.assertAlmostEqual(duration, 14.0, places=2)
def test_xfade_with_audio_aformat_normalization(self):
"""xfade 路径下多片段音频 concat 前必须经过 aformat 归一化。"""
from app.services.video_compose_service import ClipFilterChain
chains = [
ClipFilterChain(clip_id="c1", input_index=0, video_label="v0", audio_label="a0", filters=[], duration=5.0),
ClipFilterChain(clip_id="c2", input_index=1, video_label="v1", audio_label="a1", filters=[], duration=8.0),
]
filter_str, _ = _build_xfade_filter(chains, transition_duration=0.5, transitions=["fade"])
# 两个音频流都必须经过 aformat 归一化(48000Hz + stereo + fltp
aformat_count = filter_str.count("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp")
self.assertEqual(aformat_count, 2, "两个音频片段都应该有aformat归一化")
# 最终音频 concat
self.assertIn("concat=n=2:v=0:a=1[outa]", filter_str)
def test_xfade_single_audio_passthrough(self):
"""xfade 路径下只有一个音频片段时直接 acopy。"""
from app.services.video_compose_service import ClipFilterChain
chains = [
ClipFilterChain(clip_id="c1", input_index=0, video_label="v0", audio_label="a0", filters=[], duration=5.0),
ClipFilterChain(clip_id="c2", input_index=1, video_label="v1", audio_label=None, filters=[], duration=8.0),
]
filter_str, _ = _build_xfade_filter(chains, transition_duration=0.5, transitions=["fade"])
self.assertIn("[a0]acopy[outa]", filter_str)
class TestHasAudioTitleSubtitleFix(TestCase):
"""P0 修复验证:title/subtitle 片段不应有音频流。"""