eb636b8fef
CI/CD Pipeline / Check if frontend-only change (push) Waiting to run
CI/CD Pipeline / Validate - Code Quality (push) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (push) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (push) Waiting to run
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Waiting to run
CI/CD Pipeline / PR Build Web Image (push) Waiting to run
CI/CD Pipeline / PR Build Worker Image (push) Waiting to run
CI/CD Pipeline / Build Staging API Image (push) Waiting to run
CI/CD Pipeline / Build Staging Web Image (push) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (push) Waiting to run
CI/CD Pipeline / Build Production API Image (push) Waiting to run
CI/CD Pipeline / Build Production Web Image (push) Waiting to run
CI/CD Pipeline / Build Production Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
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
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- 新增纯逻辑模块 multi_track_mixer_pure.py,0 FFmpeg 依赖 - 71个单元测试全绿 - 向后兼容,原 multi_track_mixer.py 未修改
639 lines
20 KiB
Python
Executable File
639 lines
20 KiB
Python
Executable File
"""多轨混音纯逻辑单元测试."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
from video_processing.multi_track_mixer_pure import (
|
|
build_amix_filter,
|
|
build_mix_filter_complex,
|
|
build_track_filter_chain,
|
|
calculate_amix_volume_compensation,
|
|
calculate_effective_range,
|
|
calculate_total_tracks,
|
|
count_track_types,
|
|
db_to_linear,
|
|
estimate_mix_duration,
|
|
filter_enabled_tracks,
|
|
is_track_visible,
|
|
linear_to_db,
|
|
normalize_volume,
|
|
sort_tracks_by_priority,
|
|
validate_audio_track,
|
|
validate_mix_config,
|
|
)
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 时间计算测试
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCalculateEffectiveRange:
|
|
"""有效时间范围计算测试."""
|
|
|
|
def test_normal_track(self):
|
|
"""正常轨道."""
|
|
start, dur, trim = calculate_effective_range(5, 10, 30, 60)
|
|
assert start == 5.0
|
|
assert dur == 10.0
|
|
assert trim == 0.0
|
|
|
|
def test_track_longer_than_audio(self):
|
|
"""轨道时长超过音频长度."""
|
|
start, dur, trim = calculate_effective_range(0, 100, 30, 60)
|
|
assert start == 0.0
|
|
assert dur == 30.0 # 用音频全长
|
|
|
|
def test_zero_track_duration(self):
|
|
"""轨道时长为 0(用音频全长)."""
|
|
start, dur, trim = calculate_effective_range(0, 0, 30, 60)
|
|
assert start == 0.0
|
|
assert dur == 30.0
|
|
|
|
def test_negative_start_time(self):
|
|
"""负开始时间(从音频中间取)."""
|
|
start, dur, trim = calculate_effective_range(-5, 20, 30, 60)
|
|
assert start == 0.0
|
|
assert dur == 15.0 # 20 - 5 = 15
|
|
assert trim == 5.0
|
|
|
|
def test_track_after_target(self):
|
|
"""轨道完全在目标之后."""
|
|
start, dur, trim = calculate_effective_range(100, 10, 30, 60)
|
|
assert dur == 0.0
|
|
|
|
def test_track_before_zero(self):
|
|
"""轨道完全在 0 之前."""
|
|
start, dur, trim = calculate_effective_range(-50, 10, 30, 60)
|
|
assert dur == 0.0
|
|
|
|
def test_zero_audio_duration(self):
|
|
"""音频时长为 0."""
|
|
start, dur, trim = calculate_effective_range(0, 10, 0, 60)
|
|
assert dur == 0.0
|
|
|
|
def test_track_extends_beyond_target(self):
|
|
"""轨道超出目标时长."""
|
|
start, dur, trim = calculate_effective_range(50, 20, 30, 60)
|
|
assert start == 50.0
|
|
assert dur == 10.0 # 60 - 50 = 10
|
|
|
|
def test_full_target_duration(self):
|
|
"""轨道覆盖整个目标时长."""
|
|
start, dur, trim = calculate_effective_range(0, 0, 100, 60)
|
|
assert start == 0.0
|
|
assert dur == 60.0
|
|
|
|
|
|
class TestIsTrackVisible:
|
|
"""轨道可见性测试."""
|
|
|
|
def test_visible_track(self):
|
|
"""可见轨道."""
|
|
assert is_track_visible(5, 10, 30, 60) is True
|
|
|
|
def test_invisible_after_target(self):
|
|
"""目标之后不可见."""
|
|
assert is_track_visible(100, 10, 30, 60) is False
|
|
|
|
def test_invisible_zero_duration(self):
|
|
"""零时长不可见."""
|
|
assert is_track_visible(0, 0, 0, 60) is False
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 滤镜链构建测试
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBuildTrackFilterChain:
|
|
"""单轨滤镜链构建测试."""
|
|
|
|
def test_basic_structure(self):
|
|
"""基本结构:截断+音量+淡入淡出+延迟+截断."""
|
|
result = build_track_filter_chain(
|
|
volume=0.5,
|
|
fade_in=1.0,
|
|
fade_out=1.0,
|
|
effective_start=5.0,
|
|
need_duration=10.0,
|
|
trim_start=0.0,
|
|
target_duration=60.0,
|
|
)
|
|
assert "atrim=0.000:10.000" in result
|
|
assert "volume=0.500" in result
|
|
assert "afade=t=in:st=0:d=1.000" in result
|
|
assert "afade=t=out" in result
|
|
assert "adelay=5000|5000" in result
|
|
assert "atrim=0:60.000" in result
|
|
|
|
def test_volume_1_0_skipped(self):
|
|
"""音量为 1.0 不添加 volume 滤镜."""
|
|
result = build_track_filter_chain(
|
|
volume=1.0,
|
|
fade_in=0,
|
|
fade_out=0,
|
|
effective_start=0,
|
|
need_duration=10,
|
|
trim_start=0,
|
|
target_duration=60,
|
|
)
|
|
assert "volume=" not in result
|
|
|
|
def test_no_fade_in(self):
|
|
"""无淡入."""
|
|
result = build_track_filter_chain(
|
|
volume=1.0,
|
|
fade_in=0,
|
|
fade_out=2.0,
|
|
effective_start=0,
|
|
need_duration=10,
|
|
trim_start=0,
|
|
target_duration=60,
|
|
)
|
|
assert "afade=t=in" not in result
|
|
assert "afade=t=out" in result
|
|
|
|
def test_no_delay(self):
|
|
"""无延迟(effective_start 很小)."""
|
|
result = build_track_filter_chain(
|
|
volume=1.0,
|
|
fade_in=0,
|
|
fade_out=0,
|
|
effective_start=0.001,
|
|
need_duration=10,
|
|
trim_start=0,
|
|
target_duration=60,
|
|
)
|
|
assert "adelay" not in result
|
|
|
|
def test_with_delay(self):
|
|
"""有延迟."""
|
|
result = build_track_filter_chain(
|
|
volume=1.0,
|
|
fade_in=0,
|
|
fade_out=0,
|
|
effective_start=2.5,
|
|
need_duration=10,
|
|
trim_start=0,
|
|
target_duration=60,
|
|
)
|
|
assert "adelay=2500|2500" in result
|
|
|
|
def test_fade_in_longer_than_duration(self):
|
|
"""淡入超过总时长,不添加淡入."""
|
|
result = build_track_filter_chain(
|
|
volume=1.0,
|
|
fade_in=20,
|
|
fade_out=0,
|
|
effective_start=0,
|
|
need_duration=10,
|
|
trim_start=0,
|
|
target_duration=60,
|
|
)
|
|
assert "afade=t=in" not in result
|
|
|
|
def test_fade_out_at_start(self):
|
|
"""淡出从 0 开始(很短的音频)."""
|
|
result = build_track_filter_chain(
|
|
volume=1.0,
|
|
fade_in=0,
|
|
fade_out=15,
|
|
effective_start=0,
|
|
need_duration=10,
|
|
trim_start=0,
|
|
target_duration=60,
|
|
)
|
|
# fade_out > need_duration,不添加
|
|
assert "afade=t=out" not in result
|
|
|
|
def test_trim_start_nonzero(self):
|
|
"""从音频中间开始截取."""
|
|
result = build_track_filter_chain(
|
|
volume=1.0,
|
|
fade_in=0,
|
|
fade_out=0,
|
|
effective_start=0,
|
|
need_duration=5,
|
|
trim_start=3.0,
|
|
target_duration=60,
|
|
)
|
|
assert "atrim=3.000:8.000" in result # 3.0 to 3.0+5.0
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# amix 滤镜测试
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBuildAmixFilter:
|
|
"""amix 滤镜构建测试."""
|
|
|
|
def test_two_inputs(self):
|
|
"""两路输入."""
|
|
result = build_amix_filter(2)
|
|
assert "amix=inputs=2" in result
|
|
assert "duration=longest" in result
|
|
|
|
def test_five_inputs(self):
|
|
"""五路输入."""
|
|
result = build_amix_filter(5)
|
|
assert "amix=inputs=5" in result
|
|
|
|
def test_zero_inputs(self):
|
|
"""零输入."""
|
|
assert build_amix_filter(0) == ""
|
|
|
|
def test_duration_shortest(self):
|
|
"""shortest 模式."""
|
|
result = build_amix_filter(3, "shortest")
|
|
assert "duration=shortest" in result
|
|
|
|
def test_invalid_duration_mode(self):
|
|
"""无效模式,默认 longest."""
|
|
result = build_amix_filter(3, "invalid")
|
|
assert "duration=longest" in result
|
|
|
|
|
|
class TestCalculateAmixVolumeCompensation:
|
|
"""音量补偿计算测试."""
|
|
|
|
def test_single_track(self):
|
|
"""单轨,无需补偿."""
|
|
assert calculate_amix_volume_compensation(1) == 1.0
|
|
|
|
def test_two_tracks(self):
|
|
"""两轨,补偿 2x."""
|
|
assert calculate_amix_volume_compensation(2) == 2.0
|
|
|
|
def test_five_tracks(self):
|
|
"""五轨,补偿 5x."""
|
|
assert calculate_amix_volume_compensation(5) == 5.0
|
|
|
|
def test_zero_tracks(self):
|
|
"""零轨,返回 1."""
|
|
assert calculate_amix_volume_compensation(0) == 1.0
|
|
|
|
|
|
class TestBuildMixFilterComplex:
|
|
"""完整混音滤镜测试."""
|
|
|
|
def test_with_main_and_two_tracks(self):
|
|
"""主音频 + 2 条轨道."""
|
|
result = build_mix_filter_complex(2, has_main=True)
|
|
assert "[0:a][1:a][2:a]" in result # 3 路输入
|
|
assert "amix=inputs=3" in result
|
|
assert "volume=3" in result # 3x 补偿
|
|
assert "[mixed]" in result
|
|
|
|
def test_no_main_three_tracks(self):
|
|
"""无主音频,3 条轨道."""
|
|
result = build_mix_filter_complex(3, has_main=False)
|
|
assert "[0:a][1:a][2:a]" in result
|
|
assert "amix=inputs=3" in result
|
|
assert "[mixed]" in result
|
|
|
|
def test_zero_tracks_no_main(self):
|
|
"""无轨道无主音频."""
|
|
assert build_mix_filter_complex(0, has_main=False) == ""
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 音量计算测试
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestNormalizeVolume:
|
|
"""音量规范化测试."""
|
|
|
|
def test_normal_volume(self):
|
|
"""正常音量."""
|
|
assert normalize_volume(0.5) == 0.5
|
|
|
|
def test_none_default(self):
|
|
"""None 默认 1.0."""
|
|
assert normalize_volume(None) == 1.0
|
|
|
|
def test_below_zero_clamped(self):
|
|
"""负值钳制到 0."""
|
|
assert normalize_volume(-5) == 0.0
|
|
|
|
def test_above_max_clamped(self):
|
|
"""超过上限钳制."""
|
|
assert normalize_volume(3.0) == 2.0
|
|
|
|
def test_string_input(self):
|
|
"""字符串输入."""
|
|
assert normalize_volume("0.5") == 0.5
|
|
|
|
def test_invalid_string(self):
|
|
"""无效字符串默认 1.0."""
|
|
assert normalize_volume("abc") == 1.0
|
|
|
|
|
|
class TestDbConversion:
|
|
"""dB 转换测试."""
|
|
|
|
def test_0_db_is_unity(self):
|
|
"""0 dB = 1.0."""
|
|
assert db_to_linear(0) == pytest.approx(1.0)
|
|
|
|
def test_negative_db(self):
|
|
"""负 dB < 1."""
|
|
assert db_to_linear(-6) == pytest.approx(0.5, rel=0.01)
|
|
|
|
def test_positive_db(self):
|
|
"""正 dB > 1."""
|
|
assert db_to_linear(6) == pytest.approx(2.0, rel=0.01)
|
|
|
|
def test_round_trip(self):
|
|
"""往返转换."""
|
|
original = 0.5
|
|
db = linear_to_db(original)
|
|
result = db_to_linear(db)
|
|
assert result == pytest.approx(original)
|
|
|
|
def test_zero_linear_is_negative_inf(self):
|
|
"""零线性值 = -inf dB."""
|
|
assert math.isinf(linear_to_db(0))
|
|
assert linear_to_db(0) < 0
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 轨道排序与过滤测试
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSortTracksByPriority:
|
|
"""轨道优先级排序测试."""
|
|
|
|
def test_sorted_by_priority(self):
|
|
"""按优先级排序."""
|
|
tracks = [
|
|
{"priority": 10, "name": "high"},
|
|
{"priority": 1, "name": "highest"},
|
|
{"priority": 100, "name": "low"},
|
|
]
|
|
result = sort_tracks_by_priority(tracks)
|
|
assert result[0]["name"] == "highest"
|
|
assert result[1]["name"] == "high"
|
|
assert result[2]["name"] == "low"
|
|
|
|
def test_default_priority_100(self):
|
|
"""默认优先级 100."""
|
|
tracks = [
|
|
{"priority": 50, "name": "mid"},
|
|
{"name": "default"},
|
|
]
|
|
result = sort_tracks_by_priority(tracks)
|
|
assert result[0]["name"] == "mid"
|
|
assert result[1]["name"] == "default"
|
|
|
|
def test_same_preserves_order(self):
|
|
"""同优先级保持顺序."""
|
|
tracks = [
|
|
{"priority": 10, "name": "first"},
|
|
{"priority": 10, "name": "second"},
|
|
]
|
|
result = sort_tracks_by_priority(tracks)
|
|
assert result[0]["name"] == "first"
|
|
assert result[1]["name"] == "second"
|
|
|
|
def test_empty_list(self):
|
|
"""空列表."""
|
|
assert sort_tracks_by_priority([]) == []
|
|
|
|
|
|
class TestFilterEnabledTracks:
|
|
"""启用轨道过滤测试."""
|
|
|
|
def test_all_enabled(self):
|
|
"""全部启用."""
|
|
tracks = [{"enabled": True}, {"enabled": True}]
|
|
assert len(filter_enabled_tracks(tracks)) == 2
|
|
|
|
def test_mixed(self):
|
|
"""混合."""
|
|
tracks = [
|
|
{"enabled": True, "name": "a"},
|
|
{"enabled": False, "name": "b"},
|
|
]
|
|
result = filter_enabled_tracks(tracks)
|
|
assert len(result) == 1
|
|
assert result[0]["name"] == "a"
|
|
|
|
def test_default_enabled(self):
|
|
"""默认启用."""
|
|
tracks = [{"name": "a"}]
|
|
assert len(filter_enabled_tracks(tracks)) == 1
|
|
|
|
def test_empty_list(self):
|
|
"""空列表."""
|
|
assert filter_enabled_tracks([]) == []
|
|
|
|
|
|
class TestCountTrackTypes:
|
|
"""轨道类型统计测试."""
|
|
|
|
def test_mixed_types(self):
|
|
"""混合类型."""
|
|
tracks = [
|
|
{"track_type": "bgm"},
|
|
{"track_type": "voiceover"},
|
|
{"track_type": "bgm"},
|
|
{"track_type": "sfx"},
|
|
]
|
|
counts = count_track_types(tracks)
|
|
assert counts["bgm"] == 2
|
|
assert counts["voiceover"] == 1
|
|
assert counts["sfx"] == 1
|
|
|
|
def test_default_type(self):
|
|
"""默认类型."""
|
|
tracks = [{}]
|
|
counts = count_track_types(tracks)
|
|
assert counts["unknown"] == 1
|
|
|
|
def test_empty_list(self):
|
|
"""空列表."""
|
|
assert count_track_types([]) == {}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 配置验证测试
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestValidateAudioTrack:
|
|
"""单轨验证测试."""
|
|
|
|
def test_valid_track(self):
|
|
"""合法轨道."""
|
|
ok, errors = validate_audio_track(
|
|
{
|
|
"audio_path": "/audio.mp3",
|
|
"volume": 0.8,
|
|
"fade_in": 1.0,
|
|
"fade_out": 2.0,
|
|
}
|
|
)
|
|
assert ok is True
|
|
assert len(errors) == 0
|
|
|
|
def test_missing_path(self):
|
|
"""缺路径."""
|
|
ok, errors = validate_audio_track({})
|
|
assert ok is False
|
|
assert any("audio_path" in e or "asset_id" in e for e in errors)
|
|
|
|
def test_negative_volume(self):
|
|
"""负音量."""
|
|
ok, errors = validate_audio_track(
|
|
{
|
|
"audio_path": "/a.mp3",
|
|
"volume": -1,
|
|
}
|
|
)
|
|
assert ok is False
|
|
assert any("volume" in e for e in errors)
|
|
|
|
def test_negative_fade_in(self):
|
|
"""负淡入."""
|
|
ok, errors = validate_audio_track(
|
|
{
|
|
"audio_path": "/a.mp3",
|
|
"fade_in": -1,
|
|
}
|
|
)
|
|
assert ok is False
|
|
assert any("fade_in" in e for e in errors)
|
|
|
|
def test_negative_fade_out(self):
|
|
"""负淡出."""
|
|
ok, errors = validate_audio_track(
|
|
{
|
|
"audio_path": "/a.mp3",
|
|
"fade_out": -1,
|
|
}
|
|
)
|
|
assert ok is False
|
|
assert any("fade_out" in e for e in errors)
|
|
|
|
def test_invalid_volume_type(self):
|
|
"""无效音量类型."""
|
|
ok, errors = validate_audio_track(
|
|
{
|
|
"audio_path": "/a.mp3",
|
|
"volume": "loud",
|
|
}
|
|
)
|
|
assert ok is False
|
|
assert any("volume" in e for e in errors)
|
|
|
|
def test_with_asset_id(self):
|
|
"""有 asset_id 无 audio_path 也合法."""
|
|
ok, errors = validate_audio_track({"asset_id": "123"})
|
|
assert ok is True
|
|
|
|
|
|
class TestValidateMixConfig:
|
|
"""混音配置验证测试."""
|
|
|
|
def test_valid_config(self):
|
|
"""合法配置."""
|
|
ok, errors = validate_mix_config(
|
|
{
|
|
"tracks": [
|
|
{"audio_path": "/a.mp3", "volume": 0.5},
|
|
{"audio_path": "/b.mp3", "volume": 0.8},
|
|
],
|
|
"target_duration": 60,
|
|
}
|
|
)
|
|
assert ok is True
|
|
|
|
def test_empty_tracks(self):
|
|
"""空轨道列表."""
|
|
ok, errors = validate_mix_config({"tracks": []})
|
|
assert ok is False
|
|
assert any("至少需要" in e for e in errors)
|
|
|
|
def test_invalid_track(self):
|
|
"""无效轨道."""
|
|
ok, errors = validate_mix_config(
|
|
{
|
|
"tracks": [
|
|
{"audio_path": "/a.mp3"},
|
|
{}, # 无效
|
|
],
|
|
}
|
|
)
|
|
assert ok is False
|
|
assert len(errors) >= 1
|
|
|
|
def test_negative_target_duration(self):
|
|
"""负目标时长."""
|
|
ok, errors = validate_mix_config(
|
|
{
|
|
"tracks": [{"audio_path": "/a.mp3"}],
|
|
"target_duration": -10,
|
|
}
|
|
)
|
|
assert ok is False
|
|
assert any("target_duration" in e for e in errors)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 工具函数测试
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCalculateTotalTracks:
|
|
"""总轨道数计算测试."""
|
|
|
|
def test_with_main(self):
|
|
"""含主音频."""
|
|
assert calculate_total_tracks({"tracks": [1, 2, 3]}) == 4
|
|
|
|
def test_without_main(self):
|
|
"""不含主音频."""
|
|
assert (
|
|
calculate_total_tracks(
|
|
{
|
|
"tracks": [1, 2],
|
|
"has_main_audio": False,
|
|
}
|
|
)
|
|
== 2
|
|
)
|
|
|
|
def test_empty_tracks_with_main(self):
|
|
"""无轨道,只有主音频."""
|
|
assert calculate_total_tracks({"tracks": []}) == 1
|
|
|
|
|
|
class TestEstimateMixDuration:
|
|
"""混音时长估算测试."""
|
|
|
|
def test_multiple_tracks(self):
|
|
"""多轨道取最长结束时间."""
|
|
tracks = [
|
|
{"start_time": 0, "duration": 10},
|
|
{"start_time": 5, "duration": 20}, # 结束 25
|
|
{"start_time": 2, "duration": 5},
|
|
]
|
|
assert estimate_mix_duration(tracks) == pytest.approx(25.0)
|
|
|
|
def test_empty_list(self):
|
|
"""空列表."""
|
|
assert estimate_mix_duration([]) == 0.0
|
|
|
|
def test_zero_duration_tracks_ignored(self):
|
|
"""零时长轨道忽略."""
|
|
tracks = [
|
|
{"start_time": 0, "duration": 0},
|
|
{"start_time": 5, "duration": 10},
|
|
]
|
|
assert estimate_mix_duration(tracks) == pytest.approx(15.0)
|