Files
xiaoxia-saas/tests/unit/test_gpu_direct_pipeline.py
T
xiaoxia 3be06c5763
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m0s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m25s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m21s
CI/CD Pipeline / Integration Tests (push) Successful in 3m57s
CI/CD Pipeline / Validate - Style (push) Successful in 5m28s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
AI Code Review / AI Code Review (pull_request) Successful in 6m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m24s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 9m18s
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Unit Tests (push) Successful in 12m31s
CI/CD Pipeline / Validate - Security (push) Successful in 12m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(worker): align gpu-direct title position/size with frontend preview (#2095)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-29 17:04:37 +08:00

772 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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、36@720 按 width 缩放到 64、y=71(PAD16+margin24)。"""
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=71(scale(16+24)=71),不含 h-th
assert "y=71" in fc
assert "h-th" not in fc
# 默认字号 36@720 经 1280/720 缩放 = 64
assert "fontsize=64" in fc
assert "text='默认标题'" in fc
# 默认粗体:drawtext 无原生粗体,用同色描边 borderw=1@720(1280/720→scale=2)模拟
assert "borderw=2" in fc
assert "bordercolor=0xffffff" 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 位置 y=71(scale(PAD16+margin_top24)=71)
assert "y=71" 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=71
assert "x=(w-text_w)/2+5" in joined
assert "y=71+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=71" not in fc
def test_title_margin_top_respected(self, monkeypatch):
"""margin_top 透传:100@720 → scale(16+100)=206@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)
# scale(PAD16 + margin_top100) = 116*1280/720 = 206
assert "y=206" 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=1@720 → scale=2,颜色与文字相同(白色)
assert "borderw=2" in fc
assert "bordercolor=0xffffff" 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 PAD16+24=40 → scale=71
assert "y=h-th-71" 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 36@720 → 1080w = 54
assert "fontsize=54" in fc
# top margin 40@720 → 60
assert "y=60" 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 60@720 → 107
assert "y=h-th-107" 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 40@720 → 71,stroke 2@720 → 4
assert "text='主标题'" in fc
assert "fontcolor=0xffff00" in fc
assert "fontsize=89" in fc
assert "y=71" in fc
assert "borderw=4" in fc
# 字幕:size 24@720 → 43,bottom margin 60@720 → 107
assert "text='成片全字幕'" in fc
assert "fontsize=43" in fc
assert "y=h-th-107" in fc
assert "fontcolor=0xffffff" in fc
# subtitle 默认 bold=True 也会加同色描边(width=1→2);但若主标题已有 stroke 不影响字幕独立 borderw=4(s 默认 2 像素@720→scale=4)
# 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=1 用同色描边模拟加粗。"""
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)
# 加粗模拟 borderw>=1,且 bordercolor 跟字体色一致(0xff0000)
assert "borderw=" in fc
assert "bordercolor=0xff0000" 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