From cb3b8d4a770666e5b3df935ba23130a3c0c5cb17 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 18 Jul 2026 13:47:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20legacy=E6=B8=B2=E6=9F=93=E5=BC=95?= =?UTF-8?q?=E6=93=8Econcat=E5=89=8D=E9=9F=B3=E9=A2=91=E4=B8=8D=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=8C=96=E5=AF=BC=E8=87=B4exit=3D234=20+=20=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=94=B9=E4=B8=BApad=E7=95=99=E9=BB=91=E8=BE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 音频归一化(exit=234根因): - concat前每个音频流加aformat滤镜,统一为48000Hz + stereo + fltp - 不同采样率/声道数的音频直接concat会导致FFmpeg退出码234 视频改为pad留黑边: - scale force_original_aspect_ratio=decrease(等比缩小,不裁剪) - pad到目标分辨率,居中+黑边填充 - 确保横屏/竖屏/任何分辨率素材都能正常出片,不丢失内容 关联 #406 --- .../api/app/services/video_compose_service.py | 22 +++++---- tests/unit/test_video_compose_service.py | 46 ++++++++++++++++--- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py index 21ee1c4a9..560d787f1 100755 --- a/apps/api/app/services/video_compose_service.py +++ b/apps/api/app/services/video_compose_service.py @@ -416,14 +416,14 @@ class VideoComposeService: filters: list[str] = [] - # 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop) - filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase") + # 1. scale: 等比缩放(保持比例,不裁剪) + filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease") - # 2. crop: 居中裁剪 - filters.append(f"crop={output_width}:{output_height}") + # 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容) + filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black") - # 2.5 fps: 统一帧率(concat 要求所有输入帧率一致) - # 放在 crop 之后、setpts 之前,确保分辨率和帧率都已统一 + # 3. fps: 统一帧率(concat 要求所有输入帧率一致) + # 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一 if fps and fps > 0: filters.append(f"fps={fps}") @@ -538,11 +538,17 @@ def _build_concat_filter( concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]" parts.append(concat_filter) - # 音频 concat(如果有) + # 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败 audio_parts: list[str] = [] for idx, chain in enumerate(clip_chains): if chain.audio_label: - audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]") + # aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式) + audio_filters = [ + "aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp", + f"atrim=0:{chain.duration}", + "asetpts=PTS-STARTPTS", + ] + audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{chain.audio_label}]") if audio_parts: parts.extend(audio_parts) diff --git a/tests/unit/test_video_compose_service.py b/tests/unit/test_video_compose_service.py index 0e8ae547c..d7ea8f42e 100755 --- a/tests/unit/test_video_compose_service.py +++ b/tests/unit/test_video_compose_service.py @@ -344,8 +344,8 @@ class TestBuildComposeCommand(TestCase): crf_idx = cmd.command.index("-crf") self.assertEqual(cmd.command[crf_idx + 1], "28") - def test_filter_chain_contains_scale_and_crop(self): - """滤镜链包含 scale 和 crop。""" + def test_filter_chain_contains_scale_and_pad(self): + """滤镜链包含 scale 和 pad(保持比例留黑边,不裁剪)。""" plan = _StubPlan() clips = [_make_ready_clip(plan_id=plan.id)] svc = _make_service(plan, clips) @@ -354,7 +354,9 @@ class TestBuildComposeCommand(TestCase): chain = cmd.clip_chains[0] filter_text = ",".join(chain.filters) self.assertIn("scale=", filter_text) - self.assertIn("crop=", filter_text) + self.assertIn("force_original_aspect_ratio=decrease", filter_text) + self.assertIn("pad=", filter_text) + self.assertIn("black", filter_text) self.assertIn("trim=", filter_text) def test_filter_chain_contains_fps(self): @@ -366,11 +368,11 @@ class TestBuildComposeCommand(TestCase): chain = cmd.clip_chains[0] filter_text = ",".join(chain.filters) - # fps 必须在 crop 之后、setpts 之前 - crop_idx = filter_text.index("crop=") + # fps 必须在 pad 之后、setpts 之前 + pad_idx = filter_text.index("pad=") fps_idx = filter_text.index("fps=25") setpts_idx = filter_text.index("setpts=") - self.assertGreater(fps_idx, crop_idx, "fps 应该在 crop 之后") + self.assertGreater(fps_idx, pad_idx, "fps 应该在 pad 之后") self.assertLess(fps_idx, setpts_idx, "fps 应该在 setpts 之前") def test_fps_zero_or_none_skips_fps_filter(self): @@ -533,6 +535,38 @@ class TestBuildConcatFilter(TestCase): self.assertIn("concat=n=2:v=1:a=0[outv]", filter_str) self.assertEqual(duration, 13.0) + def test_multi_clip_with_audio_aformat(self): + """多片段+音频时,每个音频流都经过 aformat 归一化再concat。""" + from app.services.video_compose_service import ClipFilterChain + + chains = [ + ClipFilterChain( + clip_id="c1", + input_index=0, + video_label="v0", + audio_label="a0", + filters=["scale=1280:720"], + duration=5.0, + ), + ClipFilterChain( + clip_id="c2", + input_index=1, + video_label="v1", + audio_label="a1", + filters=["scale=1280:720"], + duration=8.0, + ), + ] + filter_str, _ = _build_concat_filter(chains) + # 两个音频流都必须有 aformat 归一化(48000Hz + stereo + fltp) + self.assertIn("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp", filter_str) + # 音频 concat + self.assertIn("concat=n=2:v=0:a=1[outa]", filter_str) + # aformat 在 atrim 之前(先统一格式再裁剪) + aformat_idx = filter_str.index("aformat=") + atrim_idx = filter_str.index("atrim=") + self.assertLess(aformat_idx, atrim_idx, "aformat 应该在 atrim 之前") + class TestBuildXfadeFilter(TestCase): """_build_xfade_filter 测试。""" -- 2.54.0