Files
xiaoxia-saas/tests/unit/test_gpu_direct_pipeline.py
T
saas-backend-agent dfc5e5a5b6
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m12s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
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 / 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 / Deploy Staging (Watchtower auto-deploy) (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 / PR Build Worker Image (pull_request) Successful in 2m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m55s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m28s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 9m32s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 8m40s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 11m15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 11m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m46s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
fix(worker): align gpu-direct title defaults (size/margin/bold) with CPU vfb path
PR #2095 fixed title baseline positioning and width-scaling but didn't fully
align default constants with the CPU video_filter_builder path, causing GPU
rendered titles to appear slightly smaller / higher / bolder-differently than
the CPU/ASS preview the template was authored against. This shows up in both
single and batch GPU renders since every variant shares the same gpu_direct
pipeline.

Root causes in PR #2095 defaults:
- Default title size 36@720p vs config_schemas DEFAULT 48 / vfb default 48
- Top/bottom margin 40@720p (PAD16+margin24) vs vfb _scale_title_len(50)
- Subtitle bottom margin 60@720p vs vfb 50
- Faux bold used same-color 1px border (white-on-white invisible for default
  white text; red-on-red for colored) vs vfb black 2px (intentional choice
  per #2001 to avoid double-print/halo artifact)
- margin_top was treated as absolute y offset, now correctly added on top of
  base margin (matches vfb additive semantics)
- subtitle stroke/bold block referenced uninitialized s_borderw (ruff F821)
  — added proper init + stroke parsing consistent with title block

Changes:
- TITLE_DEFAULT_MARGIN_TOP/BOTTOM = 50 (was 24+PAD=40)
- SUBTITLE_DEFAULT_MARGIN_BOTTOM = 50 (was 60)
- TITLE_FAUX_BOLD_WIDTH = 2, border color = #000000 (was 1, same as text)
- default title size 48@720p (was 36)
- margin_top from cfg added on top of base 50 (additive, same as vfb)
- subtitle: init s_borderw/s_border_color, parse stroke dict before bold check
- bottom position: y = h - th - margin_bottom (correct baseline; previously
  used margin_top variable which was misleading but numerically equivalent
  before margin split; now uses explicit margin_bottom)

Tests updated to new expected scaled values (1280x720 scale=1.778: fontsize 85,
y=89, bold borderw=4 black; 1080x1920 vertical: fontsize 72, y=75;
margin_top=100 user offset => y=267). Added test_title_bold_false_disables_faux_bold.

Batch investigation notes (separate findings, not bugs in this PR):
- per-variant plan config is correctly deep-copied via clone_plan_for_variant
  (config=dict(source.config or {})); title text per-variant via titles[]
  override; voice per-variant independent download with #1749 strict guards;
  bgm merged via merge_bgm_config — batch passthrough chain is correct.
- Worker generation concurrency = 2 (compose.yml default); USER_PENDING_LIMIT=3,
  6 tasks will enqueue in two waves (first 3 → then next 3 as workers free up);
  GLOBAL_PENDING_LIMIT allows it. This is expected behavior, not a bug.
- Legacy key mismatch: subtitle_render_engine.py reads plan_config['subtitle_config']
  (snake_case) but normalize_plan_config writes 'subtitle' short key. This file
  is not imported by unified_render_service (which uses render_subtitles.py with
  explicit subtitle_config= parameter), so no runtime impact; left for separate
  cleanup.
- Frontend usePlanConfigLoader.ts reads config.title_config snake_case while
  backend writes 'title' short key — frontend-only issue for subsequent edit
  sessions, out of backend scope.
2026-09-29 18:08:18 +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、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