"""GPU 直连渲染管线:模板配置透传单测。 覆盖:标题样式(font/size/color/position/borderw/shadow)、静态字幕+ASR、BGM(volume/afade/adelay)、 额外音轨音量,以及不传 config 时的默认兼容行为。 """ from __future__ import annotations import sys import types from dataclasses import dataclass from pathlib import Path # 路径对齐(同其它 unit tests) APP_ROOT = Path(__file__).resolve().parents[2] / "apps" / "worker" sys.path.insert(0, str(APP_ROOT)) sys.path.insert(0, str(Path(__file__).resolve().parents[2])) import pytest # --------------------------------------------------------------------------- # Stub helpers # --------------------------------------------------------------------------- @dataclass class _StubSeg: text: str start: float end: float class _StubClip: """最小可用 stub:只包含 build_direct_render 需要的属性。""" def __init__( self, *, local_path: str = "/tmp/_stub_clip.mp4", duration: float = 2.0, trim_start: float = 0.0, trim_end: float = 0.0, speed: float = 1.0, transition_type: str = "cut", config: dict | None = None, storage_key: str = "", ): self.local_path = local_path self.duration = duration self.trim_start = trim_start self.trim_end = trim_end self.speed = speed self.transition_type = transition_type _cfg = dict(config or {"volume": 1.0}) if storage_key: _cfg["_storage_key"] = storage_key elif "_storage_key" not in _cfg: _cfg["_storage_key"] = "test/clip.mp4" self.config = _cfg self._width = 1280 self._height = 720 def _make_clips(n: int = 2, dur: float = 2.0) -> list[_StubClip]: return [_StubClip(duration=dur) for _ in range(n)] def _patch_pipeline_helpers(monkeypatch): """屏蔽 oss 上传和签名,避免依赖真实存储/网络;clip_has_audio/clip_volumes 通过参数传入。""" import video_processing.gpu_direct_pipeline as gdp monkeypatch.setattr( gdp, "sign_asset_url", lambda sk, expires=3600: f"https://oss.example.com/{sk}", ) monkeypatch.setattr( gdp, "upload_local_audio_and_sign", lambda p: (f"https://oss.example.com/{Path(p).name}", f"osskey/{Path(p).name}"), ) # --------------------------------------------------------------------------- # 基线:不传 config 保持旧默认行为 # --------------------------------------------------------------------------- class TestNoConfigBackwardCompat: def test_default_title_drawtext_white_top(self, monkeypatch): """不传 title_config 时:白字、top、48@720 按 width 缩放到 85、y=89(50@720p baseline)。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(2, 2.0), output_width=1280, output_height=720, output_fps=30, title_text="默认标题", total_duration=4.0, clip_has_audio=[True, True], clip_volumes=[1.0, 1.0], ) fc = " ".join(plan.filter_complex) assert "fontcolor=0xffffff" in fc # 默认 position=top → y=89(scale(50)=89,与 CPU/vfb 一致),不含 h-th assert "y=89" in fc assert "h-th" not in fc # 默认字号 48@720 经 1280/720 缩放 = 85 assert "fontsize=85" in fc assert "text='默认标题'" in fc # 默认粗体:黑色细描边 borderw=2@720(scale=4),与 CPU vfb 一致避免重影 assert "borderw=4" in fc assert "bordercolor=0x000000" in fc def test_no_subtitle_when_none(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], ) fc = " ".join(plan.filter_complex) # 无标题无字幕时,应直接 format=yuv420p[vfinal] assert "format=yuv420p[vfinal]" in fc assert "drawtext=" not in fc # --------------------------------------------------------------------------- # 标题样式:font/size/color/position/borderw/shadow # --------------------------------------------------------------------------- class TestTitleStylePassthrough: def test_title_color_hex_converted_to_bgr(self, monkeypatch): """#ff0000(红) → 0xff0000;注意我们直接按 RRGGBB 透传给 drawtext。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"text": "红色标题", "color": "#ff0000", "position": "top", "size": 60}, ) fc = " ".join(plan.filter_complex) assert "fontcolor=0xff0000" in fc # 60@720 按 1280/720 缩放 = 107 assert "fontsize=107" in fc # top 位置默认 margin 50@720p → scale=89(未传 margin_top 用默认) assert "y=89" in fc assert "h-th" not in fc def test_title_position_center(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"text": "居中标题", "position": "center"}, ) fc = " ".join(plan.filter_complex) assert "(h-text_h)/2" in fc def test_title_stroke_borderw(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={ "text": "描边标题", "stroke": {"enabled": True, "width": 4, "color": "#0000ff"}, }, ) fc = " ".join(plan.filter_complex) # stroke width 4@720 经 1280/720 缩放 = 7 assert "borderw=7" in fc assert "bordercolor=0x0000ff" in fc def test_title_shadow_produces_two_drawtext_layers(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={ "text": "阴影标题", "shadow": {"enabled": True, "offset_x": 3, "offset_y": 3, "color": "#000000@0.5"}, }, ) # filter_complex 是 list[str] drawtext_count = sum(1 for f in plan.filter_complex if "drawtext=" in f) # 阴影层 + 主字层 = 2 条 drawtext assert drawtext_count == 2 joined = " ".join(plan.filter_complex) # shadow offset 3@720 经 1280/720 缩放 = 5;默认 position=top → y=89 assert "x=(w-text_w)/2+5" in joined assert "y=89+5" in joined assert "h-th" not in joined def test_title_font_override(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"text": "自定义字体", "font": "Noto Serif CJK SC"}, ) fc = " ".join(plan.filter_complex) assert "font=Noto Serif CJK SC" in fc def test_title_disabled_hides_title(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, title_text="被禁用的标题", total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"enabled": False, "text": "被禁用的标题"}, ) fc = " ".join(plan.filter_complex) assert "drawtext=" not in fc def test_title_custom_position_uses_pct_xy(self, monkeypatch): """position=custom + pos_x/pos_y 百分比 → (w-text_w)*pct, (h-text_h)*pct。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={ "text": "拖拽标题", "position": "custom", "pos_x": 30, "pos_y": 60, }, ) fc = " ".join(plan.filter_complex) assert "(w-text_w)*0.3000" in fc assert "(h-text_h)*0.6000" in fc assert "h-th-" not in fc assert "y=89" not in fc def test_title_margin_top_respected(self, monkeypatch): """margin_top 透传:100@720,叠加默认 50 → 150@720 → scale=267@1280。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"text": "远离顶部", "position": "top", "margin_top": 100}, ) fc = " ".join(plan.filter_complex) # 默认 50 + margin_top100 = 150@720 → scale=267 assert "y=267" in fc def test_title_default_bold_true(self, monkeypatch): """不传 bold 时默认粗体:drawtext 用同色描边 borderw 模拟。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"text": "粗体"}, ) fc = " ".join(plan.filter_complex) # bold=True 默认:黑色细描边 width=2@720 → scale=4,与 CPU vfb 一致 assert "borderw=4" in fc assert "bordercolor=0x000000" in fc assert "text='粗体'" in fc def test_title_bold_false_disables_faux_bold(self, monkeypatch): """显式 bold=False 时不加粗描边。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"text": "细体", "bold": False}, ) fc = " ".join(plan.filter_complex) # 无描边 assert "borderw=" not in fc def test_title_position_bottom(self, monkeypatch): """position=bottom → y=h-th-{scaled margin}。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"text": "底部", "position": "bottom"}, ) fc = " ".join(plan.filter_complex) # bottom margin 50@720 → scale=89 assert "y=h-th-89" in fc def test_title_size_scales_by_width_not_height(self, monkeypatch): """不同分辨率下同 @720 基准的 size 等比缩放:1080x1920 下 36→54。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1080, output_height=1920, output_fps=30, title_text="竖屏标题", total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], ) fc = " ".join(plan.filter_complex) # default size 48@720 → 1080w = 72 assert "fontsize=72" in fc # top margin 50@720 → 75 assert "y=75" in fc # --------------------------------------------------------------------------- # 字幕:静态 subtitle_text + ASR segments # --------------------------------------------------------------------------- class TestSubtitlePassthrough: def test_static_subtitle_spans_full_duration(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(2, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=4.0, clip_has_audio=[True, True], clip_volumes=[1.0, 1.0], subtitle_config={"enabled": True, "text": "这是静态字幕", "color": "#00ff00"}, static_subtitle_text="这是静态字幕", ) fc = " ".join(plan.filter_complex) # 应出现 static 文本,且 enable 范围 0 → 4.0 assert "text='这是静态字幕'" in fc assert "between(t,0.000,4.000)" in fc assert "fontcolor=0x00ff00" in fc def test_asr_segments_use_subtitle_style(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) segs = [ _StubSeg("第一句", 0.0, 1.5), _StubSeg("第二句", 1.5, 3.0), ] plan = gdp.build_direct_render( resolved_clips=_make_clips(2, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=4.0, clip_has_audio=[True, True], clip_volumes=[1.0, 1.0], subtitle_segments=segs, subtitle_config={"enabled": True, "color": "#0000ff", "size": 28, "position": "bottom"}, ) fc = " ".join(plan.filter_complex) assert "text='第一句'" in fc assert "text='第二句'" in fc assert "fontcolor=0x0000ff" in fc # sub size 28@720 经 1280/720 缩放 = 50 assert "fontsize=50" in fc # bottom margin 50@720 → 89 assert "y=h-th-89" in fc def test_subtitle_disabled_hides_subs(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], subtitle_config={"enabled": False, "text": "我被关了"}, static_subtitle_text="我被关了", ) fc = " ".join(plan.filter_complex) assert "drawtext=" not in fc def test_subtitle_short_hex_color(self, monkeypatch): """#fff → 0xffffff(缩写展开)。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], subtitle_config={"text": "短色", "color": "#fff"}, static_subtitle_text="短色", ) fc = " ".join(plan.filter_complex) assert "fontcolor=0xffffff" in fc # --------------------------------------------------------------------------- # BGM:volume / afade / adelay # --------------------------------------------------------------------------- class TestBGMConfigPassthrough: def test_bgm_volume_from_config(self, monkeypatch, tmp_path): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) bgm = tmp_path / "bgm.mp3" bgm.write_bytes(b"ID3fake") plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=4.0, clip_has_audio=[True], clip_volumes=[1.0], bgm_audio=bgm, bgm_config={"volume": 0.15, "enabled": True}, ) # BGM 音频滤镜链必须含 volume=0.15(挑输出 label 为 [au_bgm] 的那条) bgm_chain = [f for f in plan.filter_complex if f.rstrip().endswith("[au_bgm]")] assert len(bgm_chain) == 1, bgm_chain assert "volume=0.150" in bgm_chain[0] def test_bgm_fade_in_out(self, monkeypatch, tmp_path): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) bgm = tmp_path / "bgm.mp3" bgm.write_bytes(b"ID3fake") plan = gdp.build_direct_render( resolved_clips=_make_clips(2, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=4.0, clip_has_audio=[True, True], clip_volumes=[1.0, 1.0], bgm_audio=bgm, bgm_config={"volume": 0.3, "fade_in": 1.0, "fade_out": 1.5}, ) bgm_chain = [f for f in plan.filter_complex if f.rstrip().endswith("[au_bgm]")][0] assert "afade=t=in:st=0:d=1.00" in bgm_chain # fade_out 起点 = total_duration - fade_out = 2.5 assert "afade=t=out:st=2.50:d=1.50" in bgm_chain def test_bgm_audio_offset_adelay(self, monkeypatch, tmp_path): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) bgm = tmp_path / "bgm.mp3" bgm.write_bytes(b"ID3fake") plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=4.0, clip_has_audio=[True], clip_volumes=[1.0], bgm_audio=bgm, bgm_config={"audio_offset": 2.5}, ) bgm_chain = [f for f in plan.filter_complex if f.rstrip().endswith("[au_bgm]")][0] # adelay 毫秒(2.5s → 2500),立体声双声道 assert "adelay=2500|2500" in bgm_chain def test_bgm_volume_adjust_db(self, monkeypatch, tmp_path): """volume_adjust_db=-6dB → 增益 0.5,最终 volume 约 0.3*0.5=0.15。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) bgm = tmp_path / "bgm.mp3" bgm.write_bytes(b"ID3fake") plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=4.0, clip_has_audio=[True], clip_volumes=[1.0], bgm_audio=bgm, bgm_config={"volume": 0.3, "volume_adjust_db": -6.0}, ) bgm_chain = [f for f in plan.filter_complex if f.rstrip().endswith("[au_bgm]")][0] # 0.3 * 10^(-6/20) ≈ 0.3 * 0.501 ≈ 0.150 assert "volume=0.150" in bgm_chain def test_bgm_disabled_drops_bgm_even_if_path_present(self, monkeypatch, tmp_path): """bgm_config.enabled=False 时即使传 bgm_audio 也不挂载。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) bgm = tmp_path / "bgm.mp3" bgm.write_bytes(b"ID3fake") plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], bgm_audio=bgm, bgm_config={"enabled": False, "volume": 0.3}, ) fc = " ".join(plan.filter_complex) assert "au_bgm" not in fc # --------------------------------------------------------------------------- # extra_audio_tracks 音量透传 # --------------------------------------------------------------------------- class TestExtraAudioVolume: def test_extra_audio_uses_passed_volume(self, monkeypatch, tmp_path): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) tts = tmp_path / "tts.m4a" tts.write_bytes(b"fake") plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], extra_audio_tracks=[(tts, 0.7)], ) # extra 音轨链应带 volume=0.7 extras = [f for f in plan.filter_complex if "aex" in f and "volume" in f] assert any("volume=0.70" in e for e in extras) # --------------------------------------------------------------------------- # 端到端:多配置组合 → filter_complex 无语法碎片 # --------------------------------------------------------------------------- class TestCombinedConfig: def test_title_static_sub_bgm_combined(self, monkeypatch, tmp_path): import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) bgm = tmp_path / "bgm.mp3" bgm.write_bytes(b"ID3fake") plan = gdp.build_direct_render( resolved_clips=_make_clips(2, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=4.0, clip_has_audio=[True, True], clip_volumes=[1.0, 1.0], bgm_audio=bgm, title_config={ "text": "主标题", "color": "#ffff00", "position": "top", "size": 50, "stroke": {"enabled": True, "width": 2, "color": "#000000"}, }, subtitle_config={"text": "成片全字幕", "color": "#ffffff", "size": 24, "position": "bottom"}, static_subtitle_text="成片全字幕", bgm_config={"volume": 0.2, "fade_in": 0.5, "fade_out": 1.0}, ) fc = " ".join(plan.filter_complex) # 标题:size 50@720 → 89,top margin 50@720 → 89,stroke 2@720 → 4 assert "text='主标题'" in fc assert "fontcolor=0xffff00" in fc assert "fontsize=89" in fc assert "y=89" in fc assert "borderw=4" in fc # 字幕:size 24@720 → 43,bottom margin 50@720 → 89(默认不加粗) assert "text='成片全字幕'" in fc assert "fontsize=43" in fc assert "y=h-th-89" in fc assert "fontcolor=0xffffff" in fc # subtitle 默认 bold=False,无额外描边(用户未开 stroke) # BGM assert "volume=0.200" in fc assert "afade=t=in:st=0:d=0.50" in fc assert "afade=t=out:st=3.00:d=1.00" in fc # vfinal 存在 assert "[vfinal]" in fc # --------------------------------------------------------------------------- # 额外边界用例 # --------------------------------------------------------------------------- class TestEdgeCases: def test_invalid_color_falls_back_to_white(self, monkeypatch): """非法色值回退 white,不抛异常。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"text": "T", "color": "not-a-color"}, ) fc = " ".join(plan.filter_complex) # 非法颜色不是 # 开头且不是命名,会被当命名色直接返回,不报错;确保至少 drawtext 有 assert "drawtext=" in fc def test_bold_title_increases_borderw(self, monkeypatch): """bold=True 时若原无描边,自动加 borderw 黑色细描边模拟加粗(与 CPU vfb 一致,避免重影)。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"text": "粗体", "bold": True, "color": "#ff0000"}, ) fc = " ".join(plan.filter_complex) # 仿粗用黑色细描边 2@720→scale=4,不跟文字色(避免同色描边重影) assert "borderw=4" in fc assert "bordercolor=0x000000" in fc def test_asr_and_static_subtitle_asr_wins(self, monkeypatch): """同时传 static_subtitle_text 和 ASR segments 时,ASR 优先(不插入静态全文)。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) segs = [_StubSeg("ASR1", 0.0, 1.0), _StubSeg("ASR2", 1.0, 2.0)] plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], subtitle_segments=segs, subtitle_config={"text": "静态全文", "color": "#ffffff"}, static_subtitle_text="静态全文", ) fc = " ".join(plan.filter_complex) assert "text='ASR1'" in fc assert "text='ASR2'" in fc # 静态全文不应该单独存在 assert "between(t,0.000,2.000)" not in fc or "text='静态全文'" not in fc def test_hex_color_with_alpha(self, monkeypatch): """#rrggbbaa → 0xrrggbb@A 格式。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], title_config={"text": "半透明", "color": "#ff000080"}, ) fc = " ".join(plan.filter_complex) assert "fontcolor=0xff0000@" in fc def test_bgm_invalid_volume_clamped(self, monkeypatch, tmp_path): """volume 非法值回退默认 0.3;负值 clamp 到 0。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) bgm = tmp_path / "bgm.mp3" bgm.write_bytes(b"ID3fake") plan = gdp.build_direct_render( resolved_clips=_make_clips(1, 2.0), output_width=1280, output_height=720, output_fps=30, total_duration=2.0, clip_has_audio=[True], clip_volumes=[1.0], bgm_audio=bgm, bgm_config={"volume": -999}, ) bgm_chain = [f for f in plan.filter_complex if f.rstrip().endswith("[au_bgm]")][0] assert "volume=0.000" in bgm_chain