da59a6c9a6
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 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 / 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 / Check push changed paths (push) Successful in 13s
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (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 / PR Build API Image (pull_request) Successful in 52s
CI/CD Pipeline / Build Staging API Image (push) Successful in 59s
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 / 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 Worker Image (pull_request) Has been skipped
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m50s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 55s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m32s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m29s
CI/CD Pipeline / Validate - Style (push) Successful in 4m29s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m6s
AI Code Review / AI Code Review (pull_request) Successful in 6m56s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m41s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 9m9s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 9m28s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 11m27s
CI/CD Pipeline / Validate - Security (push) Successful in 11m42s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
635 lines
23 KiB
Python
635 lines
23 KiB
Python
"""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_bottom(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,
|
||
title_text="默认标题",
|
||
total_duration=4.0,
|
||
clip_has_audio=[True, True],
|
||
clip_volumes=[1.0, 1.0],
|
||
)
|
||
fc = " ".join(plan.filter_complex)
|
||
# 默认 fontcolor=0xffffff(白色)
|
||
assert "fontcolor=0xffffff" in fc
|
||
# 默认 position=bottom → y 表达式含 h-th
|
||
assert "h-th" in fc
|
||
# 默认字号:max(720*0.05,24) = 36
|
||
assert "fontsize=36" in fc
|
||
# escape 对中文无影响(只转义 :'\ ),所以中文原样出现
|
||
assert "text='默认标题'" 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
|
||
assert "fontsize=60" in fc
|
||
# top 位置 y=40 附近(h_th 不出现)
|
||
assert "y=40" 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)
|
||
assert "borderw=4" 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)
|
||
assert "x=(w-text_w)/2+3" in joined
|
||
assert "y=h-th-" in joined and "+3" 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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 字幕:静态 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
|
||
assert "fontsize=28" 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)
|
||
# 标题
|
||
assert "text='主标题'" in fc
|
||
assert "fontcolor=0xffff00" in fc
|
||
assert "fontsize=50" in fc
|
||
assert "y=40" in fc
|
||
assert "borderw=2" in fc
|
||
# 字幕
|
||
assert "text='成片全字幕'" in fc
|
||
assert "fontcolor=0xffffff" in fc
|
||
assert "fontsize=24" in fc
|
||
# 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
|