7e172c0907
CI/CD Pipeline / Unit Tests (push) Successful in 1m33s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m25s
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
1017 lines
34 KiB
Python
Executable File
1017 lines
34 KiB
Python
Executable File
"""多轨道混音 + 字幕渲染引擎 + 视频拼接 单元测试.
|
||
|
||
测试:
|
||
1. 多轨道混音:配置解析、轨道预处理、多轨混音、降级
|
||
2. 字幕渲染引擎:样式配置、ASS生成、多源字幕合并、滤镜构建
|
||
3. 视频拼接:配置解析、stream copy、concat filter、降级
|
||
"""
|
||
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||
|
||
import pytest
|
||
|
||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.fixture
|
||
def work_dir(tmp_path):
|
||
return tmp_path
|
||
|
||
|
||
@pytest.fixture
|
||
def main_audio_path(work_dir):
|
||
"""生成 10 秒测试主音频."""
|
||
import subprocess
|
||
|
||
path = work_dir / "main.aac"
|
||
subprocess.run(
|
||
[
|
||
"ffmpeg",
|
||
"-y",
|
||
"-f",
|
||
"lavfi",
|
||
"-i",
|
||
"sine=frequency=440:duration=10:sample_rate=44100",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
str(path),
|
||
],
|
||
capture_output=True,
|
||
check=True,
|
||
timeout=30,
|
||
)
|
||
return path
|
||
|
||
|
||
@pytest.fixture
|
||
def sfx_audio_path(work_dir):
|
||
"""生成 3 秒效音频."""
|
||
import subprocess
|
||
|
||
path = work_dir / "sfx.aac"
|
||
subprocess.run(
|
||
[
|
||
"ffmpeg",
|
||
"-y",
|
||
"-f",
|
||
"lavfi",
|
||
"-i",
|
||
"sine=frequency=880:duration=3:sample_rate=44100",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
str(path),
|
||
],
|
||
capture_output=True,
|
||
check=True,
|
||
timeout=30,
|
||
)
|
||
return path
|
||
|
||
|
||
@pytest.fixture
|
||
def voiceover_audio_path(work_dir):
|
||
"""生成 5 秒配音音频."""
|
||
import subprocess
|
||
|
||
path = work_dir / "voiceover.aac"
|
||
subprocess.run(
|
||
[
|
||
"ffmpeg",
|
||
"-y",
|
||
"-f",
|
||
"lavfi",
|
||
"-i",
|
||
"sine=frequency=220:duration=5:sample_rate=44100",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
str(path),
|
||
],
|
||
capture_output=True,
|
||
check=True,
|
||
timeout=30,
|
||
)
|
||
return path
|
||
|
||
|
||
@pytest.fixture
|
||
def test_video_1(work_dir):
|
||
"""生成 5 秒测试视频1(1080x1920, 30fps)."""
|
||
import subprocess
|
||
|
||
path = work_dir / "video1.mp4"
|
||
subprocess.run(
|
||
[
|
||
"ffmpeg",
|
||
"-y",
|
||
"-f",
|
||
"lavfi",
|
||
"-i",
|
||
"color=c=red:s=1080x1920:d=5:r=30",
|
||
"-f",
|
||
"lavfi",
|
||
"-i",
|
||
"sine=frequency=440:duration=5:sample_rate=44100",
|
||
"-c:v",
|
||
"libx264",
|
||
"-preset",
|
||
"ultrafast",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
"-shortest",
|
||
str(path),
|
||
],
|
||
capture_output=True,
|
||
check=True,
|
||
timeout=60,
|
||
)
|
||
return path
|
||
|
||
|
||
@pytest.fixture
|
||
def test_video_2(work_dir):
|
||
"""生成 5 秒测试视频2(1080x1920, 30fps)."""
|
||
import subprocess
|
||
|
||
path = work_dir / "video2.mp4"
|
||
subprocess.run(
|
||
[
|
||
"ffmpeg",
|
||
"-y",
|
||
"-f",
|
||
"lavfi",
|
||
"-i",
|
||
"color=c=blue:s=1080x1920:d=5:r=30",
|
||
"-f",
|
||
"lavfi",
|
||
"-i",
|
||
"sine=frequency=660:duration=5:sample_rate=44100",
|
||
"-c:v",
|
||
"libx264",
|
||
"-preset",
|
||
"ultrafast",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
"-shortest",
|
||
str(path),
|
||
],
|
||
capture_output=True,
|
||
check=True,
|
||
timeout=60,
|
||
)
|
||
return path
|
||
|
||
|
||
# ============================================================================
|
||
# 一、多轨道混音测试
|
||
# ============================================================================
|
||
|
||
|
||
class TestAudioTrack:
|
||
"""AudioTrack 配置解析测试."""
|
||
|
||
def test_default_values(self):
|
||
from video_processing.multi_track_mixer import AudioTrack
|
||
|
||
track = AudioTrack.from_dict({"track_id": "t1", "audio_path": "/tmp/test.aac"})
|
||
assert track.track_id == "t1"
|
||
assert track.audio_path == "/tmp/test.aac"
|
||
assert track.volume == pytest.approx(0.7) # sfx 默认音量
|
||
assert track.track_type == "sfx"
|
||
assert track.enabled is True
|
||
assert track.fade_in == 0.0
|
||
assert track.start_time == 0.0
|
||
|
||
def test_volume_clamping(self):
|
||
from video_processing.multi_track_mixer import AudioTrack
|
||
|
||
track = AudioTrack.from_dict(
|
||
{
|
||
"track_id": "t1",
|
||
"audio_path": "/tmp/test.aac",
|
||
"volume": 5.0,
|
||
}
|
||
)
|
||
assert track.volume == pytest.approx(2.0) # 上限钳制
|
||
|
||
track2 = AudioTrack.from_dict(
|
||
{
|
||
"track_id": "t2",
|
||
"audio_path": "/tmp/test.aac",
|
||
"volume": -1.0,
|
||
}
|
||
)
|
||
assert track2.volume == pytest.approx(0.0) # 下限钳制
|
||
|
||
def test_track_type_default_volume(self):
|
||
from video_processing.multi_track_mixer import DEFAULT_VOLUMES, AudioTrack
|
||
|
||
for track_type, expected_vol in DEFAULT_VOLUMES.items():
|
||
track = AudioTrack.from_dict(
|
||
{
|
||
"track_id": "t1",
|
||
"track_type": track_type,
|
||
"audio_path": "/tmp/test.aac",
|
||
}
|
||
)
|
||
assert track.volume == pytest.approx(expected_vol)
|
||
|
||
def test_invalid_config_safe(self):
|
||
from video_processing.multi_track_mixer import AudioTrack
|
||
|
||
# 无效值应该安全降级到默认值
|
||
track = AudioTrack.from_dict(
|
||
{
|
||
"track_id": "t1",
|
||
"audio_path": "/tmp/test.aac",
|
||
"volume": "invalid",
|
||
"fade_in": "abc",
|
||
"start_time": None,
|
||
}
|
||
)
|
||
assert track.volume > 0 # 有默认值
|
||
assert track.fade_in == 0.0
|
||
assert track.start_time == 0.0
|
||
|
||
def test_disabled_track(self):
|
||
from video_processing.multi_track_mixer import AudioTrack
|
||
|
||
track = AudioTrack.from_dict(
|
||
{
|
||
"track_id": "t1",
|
||
"audio_path": "/tmp/test.aac",
|
||
"enabled": False,
|
||
}
|
||
)
|
||
assert track.enabled is False
|
||
|
||
|
||
class TestMultiTrackMixConfig:
|
||
"""MultiTrackMixConfig 配置解析测试."""
|
||
|
||
def test_empty_config(self):
|
||
from video_processing.multi_track_mixer import MultiTrackMixConfig
|
||
|
||
config = MultiTrackMixConfig.from_config_dict(None)
|
||
assert config.has_effect is False
|
||
assert len(config.tracks) == 0
|
||
|
||
def test_empty_dict(self):
|
||
from video_processing.multi_track_mixer import MultiTrackMixConfig
|
||
|
||
config = MultiTrackMixConfig.from_config_dict({})
|
||
assert config.has_effect is False
|
||
assert len(config.tracks) == 0
|
||
|
||
def test_valid_tracks(self):
|
||
from video_processing.multi_track_mixer import MultiTrackMixConfig
|
||
|
||
config = MultiTrackMixConfig.from_config_dict(
|
||
{
|
||
"tracks": [
|
||
{"track_id": "sfx1", "track_type": "sfx", "audio_path": "/tmp/sfx1.aac", "volume": 0.5},
|
||
{"track_id": "vo1", "track_type": "voiceover", "audio_path": "/tmp/vo1.aac"},
|
||
],
|
||
"master_volume": 0.8,
|
||
}
|
||
)
|
||
assert config.has_effect is True
|
||
assert len(config.tracks) == 2
|
||
assert config.tracks[0].volume == pytest.approx(0.5)
|
||
assert config.master_volume == pytest.approx(0.8)
|
||
|
||
def test_skip_invalid_tracks(self):
|
||
from video_processing.multi_track_mixer import MultiTrackMixConfig
|
||
|
||
config = MultiTrackMixConfig.from_config_dict(
|
||
{
|
||
"tracks": [
|
||
{"track_id": "valid", "audio_path": "/tmp/valid.aac"},
|
||
{"track_id": "no_path"}, # 没有 audio_path,应该跳过
|
||
"not_a_dict", # 不是字典,应该跳过
|
||
{"track_id": "disabled", "audio_path": "/tmp/dis.aac", "enabled": False},
|
||
],
|
||
}
|
||
)
|
||
# 只有 valid 一个有效(disabled 的也跳过)
|
||
assert len([t for t in config.tracks if t.enabled]) == 1
|
||
|
||
|
||
class TestMultiTrackMix:
|
||
"""多轨道混音集成测试."""
|
||
|
||
def test_mix_two_tracks(self, work_dir, main_audio_path, sfx_audio_path):
|
||
"""主音频 + 音效轨混音."""
|
||
from video_processing.multi_track_mixer import AudioTrack, MultiTrackMixConfig, mix_multi_track
|
||
from video_processing.render_audio import RenderContext
|
||
|
||
ctx = RenderContext(work_dir=work_dir, plan_id="test")
|
||
|
||
config = MultiTrackMixConfig(
|
||
tracks=[
|
||
AudioTrack(
|
||
track_id="sfx1",
|
||
track_type="sfx",
|
||
audio_path=str(sfx_audio_path),
|
||
volume=0.5,
|
||
start_time=2.0,
|
||
),
|
||
],
|
||
master_volume=1.0,
|
||
)
|
||
|
||
output = mix_multi_track(ctx, main_audio_path, config, target_duration=10.0)
|
||
assert output.exists()
|
||
assert output.stat().st_size > 0
|
||
|
||
def test_mix_with_voiceover(self, work_dir, main_audio_path, voiceover_audio_path):
|
||
"""主音频 + 配音轨混音."""
|
||
from video_processing.multi_track_mixer import AudioTrack, MultiTrackMixConfig, mix_multi_track
|
||
from video_processing.render_audio import RenderContext
|
||
|
||
ctx = RenderContext(work_dir=work_dir, plan_id="test2")
|
||
|
||
config = MultiTrackMixConfig(
|
||
tracks=[
|
||
AudioTrack(
|
||
track_id="vo1",
|
||
track_type="voiceover",
|
||
audio_path=str(voiceover_audio_path),
|
||
volume=1.0,
|
||
start_time=1.0,
|
||
fade_in=0.5,
|
||
fade_out=0.5,
|
||
),
|
||
],
|
||
)
|
||
|
||
output = mix_multi_track(ctx, main_audio_path, config, target_duration=10.0)
|
||
assert output.exists()
|
||
assert output.stat().st_size > 0
|
||
|
||
def test_no_tracks_returns_main(self, work_dir, main_audio_path):
|
||
"""没有附加轨道时返回主音频副本."""
|
||
from video_processing.multi_track_mixer import MultiTrackMixConfig, mix_multi_track
|
||
from video_processing.render_audio import RenderContext
|
||
|
||
ctx = RenderContext(work_dir=work_dir, plan_id="test3")
|
||
config = MultiTrackMixConfig(tracks=[])
|
||
|
||
output = mix_multi_track(ctx, main_audio_path, config, target_duration=10.0)
|
||
assert output.exists()
|
||
assert output.stat().st_size > 0
|
||
|
||
def test_mix_with_fade(self, work_dir, main_audio_path, sfx_audio_path):
|
||
"""带淡入淡出的混音."""
|
||
from video_processing.multi_track_mixer import AudioTrack, MultiTrackMixConfig, mix_multi_track
|
||
from video_processing.render_audio import RenderContext
|
||
|
||
ctx = RenderContext(work_dir=work_dir, plan_id="test_fade")
|
||
|
||
config = MultiTrackMixConfig(
|
||
tracks=[
|
||
AudioTrack(
|
||
track_id="sfx_fade",
|
||
track_type="sfx",
|
||
audio_path=str(sfx_audio_path),
|
||
fade_in=0.3,
|
||
fade_out=0.3,
|
||
start_time=1.0,
|
||
),
|
||
],
|
||
)
|
||
|
||
output = mix_multi_track(ctx, main_audio_path, config, target_duration=10.0)
|
||
assert output.exists()
|
||
assert output.stat().st_size > 0
|
||
|
||
def test_mix_audio_tracks_from_config(self, work_dir, main_audio_path, sfx_audio_path):
|
||
"""从配置字典混音的便捷函数."""
|
||
from video_processing.multi_track_mixer import mix_audio_tracks_from_config
|
||
from video_processing.render_audio import RenderContext
|
||
|
||
ctx = RenderContext(work_dir=work_dir, plan_id="test_config")
|
||
|
||
config_dict = {
|
||
"tracks": [
|
||
{
|
||
"track_id": "sfx1",
|
||
"track_type": "sfx",
|
||
"audio_path": str(sfx_audio_path),
|
||
"volume": 0.6,
|
||
"start_time": 1.0,
|
||
},
|
||
],
|
||
"enabled": True,
|
||
}
|
||
|
||
output = mix_audio_tracks_from_config(ctx, main_audio_path, config_dict, 10.0)
|
||
assert output.exists()
|
||
assert output.stat().st_size > 0
|
||
|
||
def test_disabled_config_returns_main(self, work_dir, main_audio_path):
|
||
"""配置未启用时返回主音频."""
|
||
from video_processing.multi_track_mixer import mix_audio_tracks_from_config
|
||
from video_processing.render_audio import RenderContext
|
||
|
||
ctx = RenderContext(work_dir=work_dir, plan_id="test_disabled")
|
||
|
||
output = mix_audio_tracks_from_config(ctx, main_audio_path, None, 10.0)
|
||
assert output == main_audio_path # 直接返回原文件
|
||
|
||
|
||
# ============================================================================
|
||
# 二、字幕渲染引擎测试
|
||
# ============================================================================
|
||
|
||
|
||
class TestSubtitleStyle:
|
||
"""SubtitleStyle 配置解析测试."""
|
||
|
||
def test_default_style(self):
|
||
from video_processing.subtitle_render_engine import SubtitleStyle
|
||
|
||
style = SubtitleStyle.from_dict({})
|
||
assert style.font_size == 24
|
||
assert style.position == "bottom_center"
|
||
assert style.stroke_enabled is True
|
||
assert style.background_enabled is False
|
||
assert style.alignment == 2 # bottom_center → ASS alignment 2
|
||
|
||
def test_position_aliases(self):
|
||
from video_processing.subtitle_render_engine import SubtitleStyle
|
||
|
||
style = SubtitleStyle.from_dict({"position": "top"})
|
||
assert style.position == "top_center"
|
||
assert style.alignment == 8
|
||
|
||
style2 = SubtitleStyle.from_dict({"position": "bottom"})
|
||
assert style2.position == "bottom_center"
|
||
assert style2.alignment == 2
|
||
|
||
style3 = SubtitleStyle.from_dict({"position": "center"})
|
||
assert style3.position == "center"
|
||
assert style3.alignment == 5
|
||
|
||
def test_9grid_positions(self):
|
||
from video_processing.subtitle_render_engine import POSITION_ALIGNMENT, SubtitleStyle
|
||
|
||
for pos, align in POSITION_ALIGNMENT.items():
|
||
style = SubtitleStyle.from_dict({"position": pos})
|
||
assert style.position == pos
|
||
assert style.alignment == align
|
||
|
||
def test_invalid_position_fallback(self):
|
||
from video_processing.subtitle_render_engine import SubtitleStyle
|
||
|
||
style = SubtitleStyle.from_dict({"position": "invalid_position"})
|
||
assert style.position == "bottom_center" # 降级到默认
|
||
|
||
def test_background_style(self):
|
||
from video_processing.subtitle_render_engine import SubtitleStyle
|
||
|
||
style = SubtitleStyle.from_dict(
|
||
{
|
||
"background_enabled": True,
|
||
"background_color": "#000000",
|
||
"background_opacity": 0.7,
|
||
}
|
||
)
|
||
assert style.background_enabled is True
|
||
assert style.background_opacity == pytest.approx(0.7)
|
||
|
||
def test_color_conversion(self):
|
||
from video_processing.subtitle_render_engine import SubtitleStyle
|
||
|
||
style = SubtitleStyle.from_dict({"color": "#FF0000"})
|
||
# #FF0000 → &H000000FF (ASS 格式: &HAABBGGRR)
|
||
assert "FF" in style.ass_font_color
|
||
assert "0000" in style.ass_font_color # BB 和 GG 都是 00
|
||
|
||
def test_safe_type_conversion(self):
|
||
from video_processing.subtitle_render_engine import SubtitleStyle
|
||
|
||
style = SubtitleStyle.from_dict(
|
||
{
|
||
"size": "invalid",
|
||
"margin_v": None,
|
||
"bold": "true", # 字符串真值
|
||
}
|
||
)
|
||
assert style.font_size == 24 # 降级到默认
|
||
assert style.margin_v == 60
|
||
# bool("true") = True,但这是 Python 行为,可以接受
|
||
|
||
|
||
class TestSubtitleRenderEngine:
|
||
"""字幕渲染引擎测试."""
|
||
|
||
def test_empty_engine(self, work_dir):
|
||
"""空引擎生成空文件."""
|
||
from video_processing.subtitle_render_engine import SubtitleRenderEngine
|
||
|
||
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
|
||
assert engine.has_subtitles is False
|
||
|
||
output = work_dir / "empty.ass"
|
||
engine.generate_ass(output)
|
||
assert output.exists()
|
||
assert output.read_text(encoding="utf-8") == ""
|
||
|
||
def test_add_title(self, work_dir):
|
||
"""添加标题字幕."""
|
||
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
|
||
|
||
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
|
||
engine.add_title("测试标题")
|
||
assert engine.has_subtitles is True
|
||
|
||
output = work_dir / "title.ass"
|
||
result = engine.generate_ass(output)
|
||
assert result.exists()
|
||
content = result.read_text(encoding="utf-8")
|
||
assert "测试标题" in content
|
||
assert "TitleStyle" in content
|
||
|
||
def test_add_subtitle_text(self, work_dir):
|
||
"""添加整段字幕."""
|
||
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
|
||
|
||
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
|
||
engine.add_subtitle_text("这是一段字幕")
|
||
assert engine.has_subtitles is True
|
||
|
||
output = work_dir / "subtitle.ass"
|
||
engine.generate_ass(output)
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "这是一段字幕" in content
|
||
assert "SubtitleStyle" in content
|
||
|
||
def test_add_timeline_segments(self, work_dir):
|
||
"""添加时间轴字幕片段."""
|
||
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
|
||
|
||
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
|
||
segments = [
|
||
{"start": 0.0, "end": 2.0, "text": "第一段字幕"},
|
||
{"start": 2.0, "end": 5.0, "text": "第二段字幕"},
|
||
{"start": 5.0, "end": 10.0, "text": "第三段字幕"},
|
||
]
|
||
engine.add_timeline_segments(segments)
|
||
assert engine.has_subtitles is True
|
||
|
||
output = work_dir / "timeline.ass"
|
||
engine.generate_ass(output)
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "第一段字幕" in content
|
||
assert "第二段字幕" in content
|
||
assert "第三段字幕" in content
|
||
# 检查有 3 条 Dialogue 事件
|
||
assert content.count("Dialogue:") == 3
|
||
|
||
def test_mixed_sources(self, work_dir):
|
||
"""标题 + 时间轴字幕 混合."""
|
||
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
|
||
|
||
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
|
||
engine.add_title("视频标题")
|
||
engine.add_timeline_segments(
|
||
[
|
||
{"start": 0.0, "end": 3.0, "text": "ASR 结果1"},
|
||
{"start": 3.0, "end": 7.0, "text": "ASR 结果2"},
|
||
]
|
||
)
|
||
|
||
output = work_dir / "mixed.ass"
|
||
engine.generate_ass(output)
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "视频标题" in content
|
||
assert "ASR 结果1" in content
|
||
assert "ASR 结果2" in content
|
||
assert content.count("Dialogue:") == 3
|
||
|
||
def test_fade_animation(self, work_dir):
|
||
"""淡入淡出动画效果."""
|
||
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
|
||
|
||
style = SubtitleStyle(fade_in=0.5, fade_out=0.5)
|
||
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
|
||
engine.add_timeline_segments(
|
||
[{"start": 0.0, "end": 5.0, "text": "淡入淡出测试"}],
|
||
style=style,
|
||
)
|
||
|
||
output = work_dir / "fade.ass"
|
||
engine.generate_ass(output)
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "\\fad" in content # ASS 淡入淡出标签
|
||
|
||
def test_text_wrapping(self, work_dir):
|
||
"""长文本自动换行."""
|
||
from video_processing.subtitle_render_engine import SubtitleRenderEngine, SubtitleStyle
|
||
|
||
style = SubtitleStyle(max_chars_per_line=10)
|
||
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
|
||
engine.add_subtitle_text("这是一段非常长的字幕文本,应该会自动换行显示", style=style)
|
||
|
||
output = work_dir / "wrap.ass"
|
||
engine.generate_ass(output)
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "\\N" in content # ASS 换行符
|
||
|
||
def test_escape_special_chars(self, work_dir):
|
||
"""ASS 特殊字符转义."""
|
||
from video_processing.subtitle_render_engine import SubtitleRenderEngine
|
||
|
||
engine = SubtitleRenderEngine(video_width=1080, video_height=1920, video_duration=10.0)
|
||
engine.add_subtitle_text("测试{大括号}换行\n第二行")
|
||
|
||
output = work_dir / "escape.ass"
|
||
engine.generate_ass(output)
|
||
content = output.read_text(encoding="utf-8")
|
||
# 大括号应该被转义
|
||
assert "{" not in content.split("Dialogue:")[1].split("测试")[1][:10] or "(" in content
|
||
assert "\\N" in content # 换行转义
|
||
|
||
|
||
class TestBuildSubtitlesFromPlan:
|
||
"""从 plan.config 构建字幕测试."""
|
||
|
||
def test_empty_config(self, work_dir):
|
||
from video_processing.subtitle_render_engine import build_subtitles_from_plan
|
||
|
||
output = work_dir / "empty_plan.ass"
|
||
result = build_subtitles_from_plan(
|
||
output,
|
||
{},
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=10.0,
|
||
)
|
||
assert result is None
|
||
|
||
def test_title_only(self, work_dir):
|
||
from video_processing.subtitle_render_engine import build_subtitles_from_plan
|
||
|
||
output = work_dir / "title_plan.ass"
|
||
config = {
|
||
"title_config": {
|
||
"enabled": True,
|
||
"text": "我的视频标题",
|
||
"style": {"size": 48, "bold": True, "position": "top"},
|
||
}
|
||
}
|
||
result = build_subtitles_from_plan(output, config, video_width=1080, video_height=1920, video_duration=10.0)
|
||
assert result is not None
|
||
assert result.exists()
|
||
content = result.read_text(encoding="utf-8")
|
||
assert "我的视频标题" in content
|
||
|
||
def test_manual_subtitles(self, work_dir):
|
||
from video_processing.subtitle_render_engine import build_subtitles_from_plan
|
||
|
||
output = work_dir / "manual.ass"
|
||
config = {
|
||
"manual_subtitles": [
|
||
{"start": 0.0, "end": 2.0, "text": "手动字幕1"},
|
||
{"start": 2.5, "end": 5.0, "text": "手动字幕2"},
|
||
],
|
||
"manual_subtitle_style": {"size": 28, "color": "#FFFF00"},
|
||
}
|
||
result = build_subtitles_from_plan(output, config, video_width=1080, video_height=1920, video_duration=10.0)
|
||
assert result is not None
|
||
content = result.read_text(encoding="utf-8")
|
||
assert "手动字幕1" in content
|
||
assert "手动字幕2" in content
|
||
assert content.count("Dialogue:") == 2
|
||
|
||
def test_disabled_title_skipped(self, work_dir):
|
||
from video_processing.subtitle_render_engine import build_subtitles_from_plan
|
||
|
||
output = work_dir / "disabled.ass"
|
||
config = {
|
||
"title_config": {
|
||
"enabled": False,
|
||
"text": "不显示的标题",
|
||
}
|
||
}
|
||
result = build_subtitles_from_plan(output, config, video_width=1080, video_height=1920, video_duration=10.0)
|
||
assert result is None
|
||
|
||
|
||
class TestSubtitleFilter:
|
||
"""字幕滤镜构建测试."""
|
||
|
||
def test_build_subtitle_filter(self, work_dir):
|
||
from video_processing.subtitle_render_engine import build_subtitle_filter
|
||
|
||
ass_file = work_dir / "test.ass"
|
||
ass_file.write_text("test", encoding="utf-8")
|
||
|
||
result = build_subtitle_filter(
|
||
str(ass_file),
|
||
video_input_label="[v_in]",
|
||
output_label="out",
|
||
work_dir=work_dir,
|
||
)
|
||
assert "subtitles=" in result
|
||
assert "[v_in]" in result
|
||
assert "[out]" in result
|
||
|
||
def test_default_labels(self, work_dir):
|
||
from video_processing.subtitle_render_engine import build_subtitle_filter
|
||
|
||
ass_file = work_dir / "sub.ass"
|
||
ass_file.write_text("test", encoding="utf-8")
|
||
|
||
result = build_subtitle_filter(str(ass_file), work_dir=work_dir)
|
||
assert "0:v" in result
|
||
assert "[subtitled]" in result
|
||
|
||
|
||
# ============================================================================
|
||
# 三、视频拼接引擎测试
|
||
# ============================================================================
|
||
|
||
|
||
class TestConcatSegment:
|
||
"""ConcatSegment 配置解析测试."""
|
||
|
||
def test_default_values(self):
|
||
from video_processing.concat_engine import ConcatSegment
|
||
|
||
seg = ConcatSegment.from_dict({"video_path": "/tmp/test.mp4"})
|
||
assert seg.video_path == "/tmp/test.mp4"
|
||
assert seg.start_time == 0.0
|
||
assert seg.duration == 0.0
|
||
assert seg.has_audio is True
|
||
|
||
def test_trimming_config(self):
|
||
from video_processing.concat_engine import ConcatSegment
|
||
|
||
seg = ConcatSegment.from_dict(
|
||
{
|
||
"video_path": "/tmp/test.mp4",
|
||
"start_time": 5.0,
|
||
"duration": 10.0,
|
||
}
|
||
)
|
||
assert seg.start_time == 5.0
|
||
assert seg.duration == 10.0
|
||
|
||
def test_invalid_values_safe(self):
|
||
from video_processing.concat_engine import ConcatSegment
|
||
|
||
seg = ConcatSegment.from_dict(
|
||
{
|
||
"video_path": "/tmp/test.mp4",
|
||
"start_time": "invalid",
|
||
"duration": -5.0,
|
||
}
|
||
)
|
||
assert seg.start_time == 0.0
|
||
assert seg.duration == 0.0
|
||
|
||
|
||
class TestConcatConfig:
|
||
"""ConcatConfig 配置解析测试."""
|
||
|
||
def test_empty_config(self):
|
||
from video_processing.concat_engine import ConcatConfig
|
||
|
||
config = ConcatConfig.from_config_dict(None)
|
||
assert config.has_effect is False
|
||
assert config.total_segments == 0
|
||
|
||
def test_single_segment_no_effect(self):
|
||
from video_processing.concat_engine import ConcatConfig
|
||
|
||
config = ConcatConfig.from_config_dict(
|
||
{
|
||
"segments": [{"video_path": "/tmp/1.mp4"}],
|
||
}
|
||
)
|
||
assert config.has_effect is False # 只有一段不需要拼接
|
||
|
||
def test_multiple_segments(self):
|
||
from video_processing.concat_engine import ConcatConfig
|
||
|
||
config = ConcatConfig.from_config_dict(
|
||
{
|
||
"segments": [
|
||
{"video_path": "/tmp/1.mp4"},
|
||
{"video_path": "/tmp/2.mp4"},
|
||
{"video_path": "/tmp/3.mp4"},
|
||
],
|
||
"output_width": 1080,
|
||
"output_height": 1920,
|
||
"output_fps": 30.0,
|
||
}
|
||
)
|
||
assert config.has_effect is True
|
||
assert config.total_segments == 3
|
||
assert config.output_width == 1080
|
||
|
||
def test_skip_invalid_segments(self):
|
||
from video_processing.concat_engine import ConcatConfig
|
||
|
||
config = ConcatConfig.from_config_dict(
|
||
{
|
||
"segments": [
|
||
{"video_path": "/tmp/1.mp4"},
|
||
{}, # 没有 path
|
||
{"video_path": ""}, # 空 path
|
||
{"video_path": "/tmp/2.mp4"},
|
||
],
|
||
}
|
||
)
|
||
assert config.total_segments == 2
|
||
assert config.has_effect is True
|
||
|
||
|
||
class TestConcatEngine:
|
||
"""视频拼接引擎集成测试."""
|
||
|
||
def test_concat_demuxer_stream_copy(self, work_dir, test_video_1, test_video_2):
|
||
"""concat demuxer 模式(stream copy)."""
|
||
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
|
||
|
||
engine = ConcatEngine(work_dir=work_dir)
|
||
config = ConcatConfig(
|
||
segments=[
|
||
ConcatSegment(video_path=str(test_video_1)),
|
||
ConcatSegment(video_path=str(test_video_2)),
|
||
],
|
||
)
|
||
|
||
output = work_dir / "concat_demuxer.mp4"
|
||
result = engine.concat_videos(config, output)
|
||
assert result.exists()
|
||
assert result.stat().st_size > 0
|
||
|
||
# 验证时长大约是两段之和(5+5=10秒)
|
||
from video_processing.ffmpeg_utils import probe_duration
|
||
|
||
dur = probe_duration(str(result))
|
||
assert dur > 8.0 # 留一些误差余量
|
||
assert dur < 12.0
|
||
|
||
def test_concat_filter_reencode(self, work_dir, test_video_1, test_video_2):
|
||
"""concat filter 模式(强制重新编码)."""
|
||
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
|
||
|
||
engine = ConcatEngine(work_dir=work_dir)
|
||
config = ConcatConfig(
|
||
segments=[
|
||
ConcatSegment(video_path=str(test_video_1)),
|
||
ConcatSegment(video_path=str(test_video_2)),
|
||
],
|
||
force_reencode=True,
|
||
)
|
||
|
||
output = work_dir / "concat_filter.mp4"
|
||
result = engine.concat_videos(config, output)
|
||
assert result.exists()
|
||
assert result.stat().st_size > 0
|
||
|
||
from video_processing.ffmpeg_utils import probe_duration
|
||
|
||
dur = probe_duration(str(result))
|
||
assert dur > 8.0
|
||
assert dur < 12.0
|
||
|
||
def test_concat_with_trimming(self, work_dir, test_video_1, test_video_2):
|
||
"""带裁剪的拼接(自动用 filter 模式)."""
|
||
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
|
||
|
||
engine = ConcatEngine(work_dir=work_dir)
|
||
config = ConcatConfig(
|
||
segments=[
|
||
ConcatSegment(video_path=str(test_video_1), start_time=1.0, duration=2.0),
|
||
ConcatSegment(video_path=str(test_video_2), start_time=0.0, duration=3.0),
|
||
],
|
||
)
|
||
|
||
output = work_dir / "concat_trimmed.mp4"
|
||
result = engine.concat_videos(config, output)
|
||
assert result.exists()
|
||
assert result.stat().st_size > 0
|
||
|
||
from video_processing.ffmpeg_utils import probe_duration
|
||
|
||
dur = probe_duration(str(result))
|
||
assert dur > 3.0 # 2+3=5秒
|
||
assert dur < 7.0
|
||
|
||
def test_single_segment_copy(self, work_dir, test_video_1):
|
||
"""单片段直接复制."""
|
||
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
|
||
|
||
engine = ConcatEngine(work_dir=work_dir)
|
||
config = ConcatConfig(
|
||
segments=[ConcatSegment(video_path=str(test_video_1))],
|
||
)
|
||
|
||
output = work_dir / "single.mp4"
|
||
result = engine.concat_videos(config, output)
|
||
assert result.exists()
|
||
|
||
def test_concat_video_files_helper(self, work_dir, test_video_1, test_video_2):
|
||
"""便捷函数 concat_video_files."""
|
||
from video_processing.concat_engine import concat_video_files
|
||
|
||
output = work_dir / "concat_helper.mp4"
|
||
result = concat_video_files(
|
||
[str(test_video_1), str(test_video_2)],
|
||
output,
|
||
work_dir=work_dir,
|
||
)
|
||
assert result.exists()
|
||
assert result.stat().st_size > 0
|
||
|
||
def test_concat_from_config(self, work_dir, test_video_1, test_video_2):
|
||
"""从配置字典拼接的便捷函数."""
|
||
from video_processing.concat_engine import concat_videos_from_config
|
||
|
||
output = work_dir / "concat_config.mp4"
|
||
config_dict = {
|
||
"segments": [
|
||
{"video_path": str(test_video_1)},
|
||
{"video_path": str(test_video_2)},
|
||
],
|
||
}
|
||
result = concat_videos_from_config(config_dict, output, work_dir=work_dir)
|
||
assert result is not None
|
||
assert result.exists()
|
||
|
||
def test_concat_empty_config_returns_none(self, work_dir):
|
||
"""空配置返回 None."""
|
||
from video_processing.concat_engine import concat_videos_from_config
|
||
|
||
output = work_dir / "empty.mp4"
|
||
result = concat_videos_from_config(None, output, work_dir=work_dir)
|
||
assert result is None
|
||
|
||
def test_three_videos_concat(self, work_dir, test_video_1, test_video_2):
|
||
"""三段视频拼接."""
|
||
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
|
||
|
||
engine = ConcatEngine(work_dir=work_dir)
|
||
config = ConcatConfig(
|
||
segments=[
|
||
ConcatSegment(video_path=str(test_video_1)),
|
||
ConcatSegment(video_path=str(test_video_2)),
|
||
ConcatSegment(video_path=str(test_video_1)),
|
||
],
|
||
force_reencode=True,
|
||
)
|
||
|
||
output = work_dir / "three_videos.mp4"
|
||
result = engine.concat_videos(config, output)
|
||
assert result.exists()
|
||
|
||
from video_processing.ffmpeg_utils import probe_duration
|
||
|
||
dur = probe_duration(str(result))
|
||
assert dur > 12.0 # 5+5+5=15秒
|
||
assert dur < 18.0
|
||
|
||
def test_output_resolution_override(self, work_dir, test_video_1, test_video_2):
|
||
"""指定输出分辨率."""
|
||
from video_processing.concat_engine import ConcatConfig, ConcatEngine, ConcatSegment
|
||
|
||
engine = ConcatEngine(work_dir=work_dir)
|
||
config = ConcatConfig(
|
||
segments=[
|
||
ConcatSegment(video_path=str(test_video_1)),
|
||
ConcatSegment(video_path=str(test_video_2)),
|
||
],
|
||
output_width=720,
|
||
output_height=1280,
|
||
force_reencode=True,
|
||
)
|
||
|
||
output = work_dir / "concat_720p.mp4"
|
||
result = engine.concat_videos(config, output)
|
||
assert result.exists()
|
||
|
||
from video_processing.ffmpeg_utils import probe_video_info
|
||
|
||
info = probe_video_info(str(result))
|
||
assert int(info.get("width", 0)) == 720
|
||
assert int(info.get("height", 0)) == 1280
|