From 9a2eb0ca60c6e74d8e9be273a237118ae3b9ea0f Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 18 Jul 2026 14:27:56 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(P0):=20legacy=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E8=A1=A5=E5=85=A85=E9=A1=B9normalize=20+=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=97=A5=E5=BF=97=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:one_take模式用横屏+不同编码/采样率的素材渲染exit=234崩溃。 根因是concat前视频/音频格式不完全统一,且错误日志只输出版本号看不到真正报错。 修复1 — 视频像素格式统一: - _build_clip_filter 加 format=yuv420p,放在pad之后fps之前 - 确保 concat 前所有片段像素格式一致(yuv420p/yuv422p/yuv444p/nv12等都统一) 修复2 — xfade路径音频归一化: - _build_xfade_filter 的音频concat前加 aformat 归一化(48000Hz+stereo+fltp) - 之前只修了concat路径,xfade(转场)路径漏了 修复3 — legacy渲染错误日志增强: - 失败时输出完整 ffmpeg 命令(前2000字符) - 输出完整 stderr 最后1500字符(真正的错误信息) - 输出 exit code - 之前只输出版本号,完全看不到 concat 失败的真实原因 测试:新增3个单测(format=yuv420p、xfade音频归一化、xfade单音频acopy),42个相关单测全过 关联:#406 --- .../api/app/services/video_compose_service.py | 31 ++++++++---- .../worker_app/tasks/edit_plan_generation.py | 29 +++++++++-- tests/unit/test_video_compose_service.py | 48 +++++++++++++++++++ 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py index 560d787f1..9809663ed 100755 --- a/apps/api/app/services/video_compose_service.py +++ b/apps/api/app/services/video_compose_service.py @@ -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: 统一像素格式为 yuv420p(H.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) diff --git a/apps/worker/worker_app/tasks/edit_plan_generation.py b/apps/worker/worker_app/tasks/edit_plan_generation.py index 38a24718b..e9a6e68e3 100755 --- a/apps/worker/worker_app/tasks/edit_plan_generation.py +++ b/apps/worker/worker_app/tasks/edit_plan_generation.py @@ -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 = getattr(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(f"--- 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(f"--- 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} diff --git a/tests/unit/test_video_compose_service.py b/tests/unit/test_video_compose_service.py index d7ea8f42e..fc231ce05 100755 --- a/tests/unit/test_video_compose_service.py +++ b/tests/unit/test_video_compose_service.py @@ -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,40 @@ 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 片段不应有音频流。""" -- 2.54.0 From be082ff6a3a4211ecb3e0bde3ad5fcd4e9b1e72d Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 18 Jul 2026 14:41:27 +0800 Subject: [PATCH 2/3] =?UTF-8?q?style:=20black=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_video_compose_service.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/unit/test_video_compose_service.py b/tests/unit/test_video_compose_service.py index fc231ce05..7bb2a24db 100755 --- a/tests/unit/test_video_compose_service.py +++ b/tests/unit/test_video_compose_service.py @@ -636,12 +636,8 @@ class TestBuildXfadeFilter(TestCase): 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 - ), + 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) @@ -655,12 +651,8 @@ class TestBuildXfadeFilter(TestCase): 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 - ), + 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) -- 2.54.0 From 957e0c12a6f015c8ea5b65a7833b957ab6374aa0 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 18 Jul 2026 15:02:01 +0800 Subject: [PATCH 3/3] =?UTF-8?q?style:=20ruff=E4=BF=AE=E5=A4=8Df-string?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E5=92=8Cgetattr=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/worker/worker_app/tasks/edit_plan_generation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/worker/worker_app/tasks/edit_plan_generation.py b/apps/worker/worker_app/tasks/edit_plan_generation.py index e9a6e68e3..4ad2696c8 100755 --- a/apps/worker/worker_app/tasks/edit_plan_generation.py +++ b/apps/worker/worker_app/tasks/edit_plan_generation.py @@ -370,7 +370,7 @@ def _render_with_legacy( # 提取完整 stderr(如果是 CalledProcessError) stderr_text = "" if hasattr(e, "stderr"): - stderr_raw = getattr(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): @@ -382,12 +382,12 @@ def _render_with_legacy( # 拼接完整错误信息:命令 + 异常 + stderr最后1500字符 error_parts = [f"FFmpeg渲染失败(exit={getattr(e, 'returncode', 'unknown')})"] - error_parts.append(f"--- cmd ---") + 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(f"--- stderr (last 1500 chars) ---") + error_parts.append("--- stderr (last 1500 chars) ---") error_parts.append(stderr_preview) error_msg = "\n".join(error_parts) -- 2.54.0