From 29ed0592a098a13ab35725e8e0e2420cf3474dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=A8=E6=88=B7CI=20Test?= Date: Fri, 10 Jul 2026 17:43:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=B0=83=E6=8D=A2=20fps=20=E4=B8=8E=20s?= =?UTF-8?q?etpts=20=E9=A1=BA=E5=BA=8F=EF=BC=8C=E4=BF=AE=E5=A4=8D=20xfade?= =?UTF-8?q?=20=E5=A4=9A=E8=A7=86=E9=A2=91=E8=BD=AC=E5=9C=BA=20PTS=20?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-3 根因:unified_render_service 中每个 clip 预处理滤镜链里, fps 在 setpts=PTS-STARTPTS 之前执行。当多个视频有不同 timebase/ 帧率时,fps 滤镜基于原始 PTS 进行帧转换,随后的 setpts 仅做 时间偏移,导致各片段 PTS 基准不一致,xfade 转场计算 offset 时 出错。 修复:将 setpts=PTS-STARTPTS 移到 fps 之前,先归一化 PTS 起 点,再统一帧率,确保所有片段在进入 xfade 前有一致的时间基准。 影响范围: - 多视频 xfade 转场模式(主修复目标) - 单视频一镜到底模式(同步修复,保持逻辑一致) 新增测试: - test_setpts_before_fps_in_xfade_inputs: 多视频 xfade 模式验证 - test_setpts_before_fps_single_clip: 单视频模式验证 --- .../unified_render_service.py | 2 +- tests/unit/test_unified_render_service.py | 83 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) mode change 100644 => 100755 apps/worker/video_processing/unified_render_service.py mode change 100644 => 100755 tests/unit/test_unified_render_service.py diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py old mode 100644 new mode 100755 index a01c42c7e..0235e65a8 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -339,8 +339,8 @@ class UnifiedRenderService: ) filters.append(f"pad={self.output_width}:{self.output_height}" ":(ow-iw)/2:(oh-ih)/2:black") - filters.append(f"fps={self.output_fps}") filters.append("setpts=PTS-STARTPTS") + filters.append(f"fps={self.output_fps}") filter_str = f"[{i}:v]{','.join(filters)}[{label}]" filter_parts.append(filter_str) diff --git a/tests/unit/test_unified_render_service.py b/tests/unit/test_unified_render_service.py old mode 100644 new mode 100755 index 4670c132b..8cae2ad27 --- a/tests/unit/test_unified_render_service.py +++ b/tests/unit/test_unified_render_service.py @@ -356,6 +356,89 @@ class TestBuildFilterComplex: assert "overlay=" in fc assert "[final_video]" in fc + def test_setpts_before_fps_in_xfade_inputs(self): + """多视频 xfade 模式:setpts=PTS-STARTPTS 必须在 fps 之前,确保 xfade 时各片段 PTS 一致。 + + 构造两个不同时长的视频片段,验证生成的 filter_complex 中每个片段的 + 预处理滤镜链里 setpts 都在 fps 前面。 + """ + clips = [ + _make_clip("c1", "main", order=0, duration=3.0), + _make_clip("c2", "main", order=1, duration=5.0), + ] + asset_paths = { + "asset_c1.mp4": Path("/tmp/asset_c1.mp4"), + "asset_c2.mp4": Path("/tmp/asset_c2.mp4"), + } + svc = _make_service(clips, asset_paths) + + with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + fc, _ = svc._build_filter_complex(layers) + + # 确保 xfade 存在 + assert "xfade=" in fc + + # 提取每个 clip 的预处理滤镜链([i:v]...[vi] 部分) + # 验证:每个 clip 滤镜链中,setpts=PTS-STARTPTS 的最后一次出现 + # 必须在 fps= 的前面(PTS 归一化后再统一帧率) + import re + + clip_pattern = re.compile(r"\[(\d+):v\](.+?)\[v\d+\]") + matches = clip_pattern.findall(fc) + assert len(matches) == 2, f"Expected 2 clip preprocessing chains, got {len(matches)}" + + for idx, chain_str in matches: + # 找到所有 setpts 和 fps 的位置 + setpts_positions = [m.start() for m in re.finditer(r"setpts=PTS-STARTPTS", chain_str)] + fps_positions = [m.start() for m in re.finditer(r"fps=\d+", chain_str)] + + assert setpts_positions, f"clip {idx}: 未找到 setpts=PTS-STARTPTS" + assert fps_positions, f"clip {idx}: 未找到 fps=" + + # 最后一个 setpts 必须在第一个 fps 之前 + last_setpts = max(setpts_positions) + first_fps = min(fps_positions) + assert last_setpts < first_fps, ( + f"clip {idx}: setpts(position={last_setpts}) 应该在 fps(position={first_fps}) 之前。" + f"滤镜链: {chain_str}" + ) + + def test_setpts_before_fps_single_clip(self): + """单视频模式(一镜到底):setpts 也必须在 fps 之前。 + + 单视频虽然没有 xfade,但滤镜链顺序应保持一致,确保 PTS 处理逻辑统一。 + """ + clips = [_make_clip("c1", "main", order=0, duration=5.0)] + asset_paths = {"asset_c1.mp4": Path("/tmp/asset_c1.mp4")} + svc = _make_service(clips, asset_paths) + + with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + fc, _ = svc._build_filter_complex(layers) + + import re + + clip_pattern = re.compile(r"\[(\d+):v\](.+?)\[v\d+\]") + matches = clip_pattern.findall(fc) + assert len(matches) == 1 + + chain_str = matches[0][1] + setpts_positions = [m.start() for m in re.finditer(r"setpts=PTS-STARTPTS", chain_str)] + fps_positions = [m.start() for m in re.finditer(r"fps=\d+", chain_str)] + + assert setpts_positions, "单视频: 未找到 setpts=PTS-STARTPTS" + assert fps_positions, "单视频: 未找到 fps=" + + last_setpts = max(setpts_positions) + first_fps = min(fps_positions) + assert last_setpts < first_fps, ( + f"单视频: setpts(position={last_setpts}) 应该在 fps(position={first_fps}) 之前。" + f"滤镜链: {chain_str}" + ) + def test_empty_layers_raises(self): """空图层列表抛出 ValueError。""" svc = _make_service() -- 2.54.0