From 9a4e715fd563a3193d3e4b5047592ba8dd5cc072 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 26 Aug 2026 00:06:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E6=B7=B7=E9=9F=B3=E4=B8=BB=E8=A7=86?= =?UTF-8?q?=E9=A2=91clip=E9=9F=B3=E9=87=8F=E6=BB=A4=E9=95=9C=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=20+=20=E8=87=AA=E5=8A=A8=E6=8D=A2=E8=A1=8C=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E5=B7=B2=E6=9C=89\N=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. render_audio.py mix_with_independent_audio(): 主视频clips滤镜链 缺少 volume 滤镜,导致 TTS/配音 replace 模式设置的 volume=0 在混音路径不生效。补上 _clip_volume() 调用,与 concat_main_audio() 多clip路径保持一致。 2. ass_subtitle_builder.py _wrap_title_text(): 逐字符遍历时把 \\N 当成两个普通字符算宽度,导致已有的硬换行标记被破坏。 改为先按 \\N 分段,每段独立自动换行,最后用 \\N 拼回。 新增6个单测覆盖两个修复点。 --- apps/worker/video_processing/render_audio.py | 3 + packages/domain/ass_subtitle_builder.py | 37 ++++---- tests/unit/test_ass_subtitle_builder.py | 29 +++++++ tests/unit/test_render_audio_pure.py | 90 ++++++++++++++++++++ 4 files changed, 144 insertions(+), 15 deletions(-) diff --git a/apps/worker/video_processing/render_audio.py b/apps/worker/video_processing/render_audio.py index 329cb2d62..7dd31da46 100755 --- a/apps/worker/video_processing/render_audio.py +++ b/apps/worker/video_processing/render_audio.py @@ -512,6 +512,9 @@ def mix_with_independent_audio( clip_filters.append("asetpts=PTS-STARTPTS") else: clip_filters.append("asetpts=PTS-STARTPTS") + vol = _clip_volume(clip) + if abs(vol - 1.0) >= 1e-6: + clip_filters.append(f"volume={vol:.4f}") # aformat 归一化:concat/amix 前统一音频参数,否则不同采样率/声道会失败 clip_filters.append(AFORMAT) filter_parts.append(f"[{input_idx}:a]{','.join(clip_filters)}[ma{input_idx}]") diff --git a/packages/domain/ass_subtitle_builder.py b/packages/domain/ass_subtitle_builder.py index e80b19ff2..0948bccd5 100755 --- a/packages/domain/ass_subtitle_builder.py +++ b/packages/domain/ass_subtitle_builder.py @@ -197,26 +197,33 @@ def _wrap_title_text( if available_width <= 0: return text - lines: list[str] = [] - current_line = "" - current_width = 0.0 + # 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回 + segments = text.split("\\N") + wrapped_segments: list[str] = [] - for ch in text: - # CJK 字符按全角估算,其他按半角 - char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55 + for seg in segments: + lines: list[str] = [] + current_line = "" + current_width = 0.0 - if current_width + char_width > available_width and current_line: + for ch in seg: + # CJK 字符按全角估算,其他按半角 + char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55 + + if current_width + char_width > available_width and current_line: + lines.append(current_line) + current_line = ch + current_width = char_width + else: + current_line += ch + current_width += char_width + + if current_line: lines.append(current_line) - current_line = ch - current_width = char_width - else: - current_line += ch - current_width += char_width - if current_line: - lines.append(current_line) + wrapped_segments.append("\\N".join(lines)) - return "\\N".join(lines) + return "\\N".join(wrapped_segments) def build_ass_content( diff --git a/tests/unit/test_ass_subtitle_builder.py b/tests/unit/test_ass_subtitle_builder.py index b2f6e2da4..739f28454 100755 --- a/tests/unit/test_ass_subtitle_builder.py +++ b/tests/unit/test_ass_subtitle_builder.py @@ -504,6 +504,35 @@ class TestWrapTitleText: """字号为0时直接返回原文。""" assert _wrap_title_text("测试", 1080, 0) == "测试" + def test_preserves_explicit_newline(self): + """已有的 \\N 换行标记应保留,不被当普通字符算宽度。""" + text = "第一行\\N第二行" + result = _wrap_title_text(text, video_width=1080, font_size=48) + assert result == text + + def test_explicit_newline_each_segment_wraps_independently(self): + """\\N 分段后,每段各自自动换行。""" + # 480px 宽,48px 字号,可用 400px,每段约8个中文字 + text = "这是第一段很长很长很长的内容\\N这是第二段也很长很长的内容" + result = _wrap_title_text(text, video_width=480, font_size=48) + # 应该有多个 \N:用户手动的 + 自动换行的 + assert "\\N" in result + segments = result.split("\\N") + # 至少3行(两段都需要换行) + assert len(segments) >= 3 + # 验证包含两段的文字 + joined = result.replace("\\N", "") + assert "第一段" in joined + assert "第二段" in joined + + def test_multiple_explicit_newlines(self): + """多个 \\N 分段都应保留。""" + text = "A\\NB\\NC" + result = _wrap_title_text(text, video_width=1080, font_size=48) + assert result == text + assert result.count("\\N") == 2 + + def test_build_ass_content_integration(self): """集成测试:build_ass_content 中的标题应该自动换行。""" long_title = "这是一段非常长的标题文字用于测试自动换行功能是否正常工作" diff --git a/tests/unit/test_render_audio_pure.py b/tests/unit/test_render_audio_pure.py index bedd77eb9..dce85e80e 100755 --- a/tests/unit/test_render_audio_pure.py +++ b/tests/unit/test_render_audio_pure.py @@ -11,6 +11,7 @@ from video_processing.render_audio import ( RenderContext, clip_effective_duration, clip_has_audio, + mix_with_independent_audio, ) from video_processing.unified_render_service import ResolvedClip @@ -153,3 +154,92 @@ class TestRenderContext: """音频缓存初始为空.""" ctx = _make_ctx() assert ctx._audio_cache == {} + +class TestMixWithIndependentAudioVolume: + """测试 mix_with_independent_audio 中主视频 clip 音量滤镜是否生效。""" + + def _make_clip(self, volume=None, clip_id="c1", duration=5.0): + """创建测试用 ResolvedClip。""" + config = {} + if volume is not None: + config["volume"] = volume + return ResolvedClip( + clip_id=clip_id, + asset_id=f"a_{clip_id}", + local_path=Path(f"/tmp/{clip_id}.mp4"), + clip_type="main", + order=0, + duration=duration, + actual_duration=duration, + config=config, + ) + + def test_main_clip_volume_zero_applied_in_filter(self): + """volume=0 的主视频 clip 应在 filter_complex 中包含 volume=0.0000。""" + from unittest.mock import patch + + ctx = _make_ctx() + main_clip = self._make_clip(volume=0, clip_id="m1") + captured_cmd = {} + + def fake_run_ffmpeg(cmd): + captured_cmd["cmd"] = cmd + + with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg): + mix_with_independent_audio( + ctx=ctx, + main_clips=[main_clip], + audio_clips=[], + output_path=Path("/tmp/out.m4a"), + video_duration=5.0, + ) + + filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1] + assert "volume=0.0000" in filter_complex, f"volume filter missing in: {filter_complex}" + + def test_main_clip_default_volume_no_filter(self): + """默认 volume=1.0 时不应添加 volume 滤镜。""" + from unittest.mock import patch + + ctx = _make_ctx() + main_clip = self._make_clip(clip_id="m1") # no volume set + captured_cmd = {} + + def fake_run_ffmpeg(cmd): + captured_cmd["cmd"] = cmd + + with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg): + mix_with_independent_audio( + ctx=ctx, + main_clips=[main_clip], + audio_clips=[], + output_path=Path("/tmp/out.m4a"), + video_duration=5.0, + ) + + filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1] + # 默认音量不应出现 volume= 滤镜 + assert "volume=" not in filter_complex, f"unexpected volume filter in: {filter_complex}" + + def test_main_clip_partial_volume_applied(self): + """volume=0.5 的主视频 clip 应包含 volume=0.5000。""" + from unittest.mock import patch + + ctx = _make_ctx() + main_clip = self._make_clip(volume=0.5, clip_id="m1") + captured_cmd = {} + + def fake_run_ffmpeg(cmd): + captured_cmd["cmd"] = cmd + + with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg): + mix_with_independent_audio( + ctx=ctx, + main_clips=[main_clip], + audio_clips=[], + output_path=Path("/tmp/out.m4a"), + video_duration=5.0, + ) + + filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1] + assert "volume=0.5000" in filter_complex, f"volume filter missing in: {filter_complex}" -- 2.54.0 From 5da3af9902191f6ba4aad594ef821455a0369c77 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 25 Aug 2026 16:10:40 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + prettier [skip ci-format-check] --- tests/unit/test_render_audio_pure.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_render_audio_pure.py b/tests/unit/test_render_audio_pure.py index dce85e80e..336291a74 100755 --- a/tests/unit/test_render_audio_pure.py +++ b/tests/unit/test_render_audio_pure.py @@ -155,6 +155,7 @@ class TestRenderContext: ctx = _make_ctx() assert ctx._audio_cache == {} + class TestMixWithIndependentAudioVolume: """测试 mix_with_independent_audio 中主视频 clip 音量滤镜是否生效。""" -- 2.54.0