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
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.
772 lines
29 KiB
Python
772 lines
29 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_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
|