diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 8e9d780b8..fbba0890f 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -1050,8 +1050,10 @@ class UnifiedRenderService: vf_str = ",".join(filters) - # 最终输出时长:取 clip 有效时长和 video_duration 的较小值 - final_duration = effective_duration + # 最终输出时长:取 clip 调速后有效时长和 video_duration 的较小值 + # 注意:必须用调速后的时长,否则减速场景(speed<1)会被 -t 截断 + adjusted_duration = UnifiedRenderService._clip_adjusted_duration(clip) + final_duration = adjusted_duration if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration): final_duration = video_duration diff --git a/tests/unit/test_unified_render_service.py b/tests/unit/test_unified_render_service.py index 837ff2d0f..f2bba9f49 100755 --- a/tests/unit/test_unified_render_service.py +++ b/tests/unit/test_unified_render_service.py @@ -47,6 +47,7 @@ class FakeClip: transition_effect: str = "cut" transition_duration: float = 0.0 status: str = "ready" + playback_speed: float = 1.0 config: dict[str, Any] = field(default_factory=dict) @@ -68,6 +69,7 @@ def _make_clip( transition_effect: str = "cut", transition_duration: float = 0.0, config: dict[str, Any] | None = None, + playback_speed: float = 1.0, ) -> FakeClip: return FakeClip( id=clip_id, @@ -78,6 +80,7 @@ def _make_clip( transition_effect=transition_effect, transition_duration=transition_duration, config=config or {}, + playback_speed=playback_speed, ) @@ -1925,6 +1928,88 @@ class TestConcatNormalizeVideoResolution: vf_value = cmd[vf_idx + 1] assert "crop=" in vf_value, "background 层应有 crop 滤镜" + def test_pass_through_speed_up_correct_duration(self): + """直通模式加速(speed=2x):视频+音频均调速,-t 时长为原始的 1/2。""" + clips = [_make_clip("c1", "main", order=0, duration=10.0, playback_speed=2.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=10.0), + patch("video_processing.unified_render_service.run_ffmpeg") as mock_run, + ): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + svc._render_pass_through(layers, Path("/tmp/out.mp4")) + + assert mock_run.called + cmd = mock_run.call_args[0][0] + cmd_str = " ".join(cmd) + + # 视频调速:setpts=PTS/2.0 + assert "setpts=PTS/2.0" in cmd_str, "加速场景应有 setpts=PTS/speed 滤镜" + + # 音频调速:atempo + assert "atempo" in cmd_str, "加速场景应有 atempo 音频调速滤镜" + + # 输出时长应为原始 / speed = 10 / 2 = 5 秒 + t_idx = cmd.index("-t") + t_value = float(cmd[t_idx + 1]) + assert abs(t_value - 5.0) < 0.01, f"加速后 -t 时长应为 5.0s,实际 {t_value}s" + + def test_pass_through_slow_down_correct_duration(self): + """直通模式减速(speed=0.5x):视频+音频均调速,-t 时长为原始的 2 倍(不被截断)。""" + clips = [_make_clip("c1", "main", order=0, duration=10.0, playback_speed=0.5)] + 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=10.0), + patch("video_processing.unified_render_service.run_ffmpeg") as mock_run, + ): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + svc._render_pass_through(layers, Path("/tmp/out.mp4")) + + assert mock_run.called + cmd = mock_run.call_args[0][0] + cmd_str = " ".join(cmd) + + # 视频调速:setpts=PTS/0.5 + assert "setpts=PTS/0.5" in cmd_str, "减速场景应有 setpts=PTS/speed 滤镜" + + # 音频调速:atempo + assert "atempo" in cmd_str, "减速场景应有 atempo 音频调速滤镜" + + # 输出时长应为原始 / speed = 10 / 0.5 = 20 秒(减速后视频变长,不应被截断) + t_idx = cmd.index("-t") + t_value = float(cmd[t_idx + 1]) + assert abs(t_value - 20.0) < 0.01, f"减速后 -t 时长应为 20.0s,实际 {t_value}s" + + def test_pass_through_speed_with_video_duration_cap(self): + """直通模式调速 + video_duration 截断:取调速后时长与 video_duration 的较小值。""" + clips = [_make_clip("c1", "main", order=0, duration=10.0, playback_speed=2.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=10.0), + patch("video_processing.unified_render_service.run_ffmpeg") as mock_run, + ): + resolved = svc._resolve_clips() + layers = svc._group_clips_into_layers(resolved) + # video_duration=3.0 < 调速后时长 5.0,应取 3.0 + svc._render_pass_through(layers, Path("/tmp/out.mp4"), video_duration=3.0) + + assert mock_run.called + cmd = mock_run.call_args[0][0] + t_idx = cmd.index("-t") + t_value = float(cmd[t_idx + 1]) + assert abs(t_value - 3.0) < 0.01, f"video_duration 更小时应取 video_duration,实际 {t_value}s" + class TestConcatNormalizeVideoFps: """视频帧率归一化:fps 滤镜统一到目标 fps。