style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (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 - Migration (alembic) (push) Successful in 2m9s
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 / Validate - Type Check (mypy) (push) Successful in 2m19s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m7s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m7s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m37s
CI/CD Pipeline / Integration Tests (push) Successful in 2m18s
CI/CD Pipeline / Unit Tests (push) Failing after 4m59s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 27m2s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 30m15s
CI/CD Pipeline / Check if frontend-only change (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 - Migration (alembic) (push) Successful in 2m9s
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 / Validate - Type Check (mypy) (push) Successful in 2m19s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m7s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m7s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m37s
CI/CD Pipeline / Integration Tests (push) Successful in 2m18s
CI/CD Pipeline / Unit Tests (push) Failing after 4m59s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 27m2s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 30m15s
This commit is contained in:
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from services.asr_service_factory import get_asr_service, reset_asr_service_cache
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.bgm_mixer import BGMConfig
|
||||
|
||||
|
||||
@@ -43,10 +42,13 @@ class TestBGMConfigFromConfigDict:
|
||||
|
||||
def test_fade_in_out(self):
|
||||
"""淡入淡出."""
|
||||
config = BGMConfig.from_config_dict("/a.mp3", {
|
||||
"fade_in": 2.0,
|
||||
"fade_out": 3.0,
|
||||
})
|
||||
config = BGMConfig.from_config_dict(
|
||||
"/a.mp3",
|
||||
{
|
||||
"fade_in": 2.0,
|
||||
"fade_out": 3.0,
|
||||
},
|
||||
)
|
||||
assert config.fade_in == 2.0
|
||||
assert config.fade_out == 3.0
|
||||
|
||||
@@ -62,13 +64,16 @@ class TestBGMConfigFromConfigDict:
|
||||
|
||||
def test_sidechain_custom_params(self):
|
||||
"""闪避自定义参数."""
|
||||
config = BGMConfig.from_config_dict("/a.mp3", {
|
||||
"sidechain_enabled": True,
|
||||
"sidechain_ratio": 0.5,
|
||||
"sidechain_attack": 0.05,
|
||||
"sidechain_release": 0.8,
|
||||
"sidechain_threshold": -30.0,
|
||||
})
|
||||
config = BGMConfig.from_config_dict(
|
||||
"/a.mp3",
|
||||
{
|
||||
"sidechain_enabled": True,
|
||||
"sidechain_ratio": 0.5,
|
||||
"sidechain_attack": 0.05,
|
||||
"sidechain_release": 0.8,
|
||||
"sidechain_threshold": -30.0,
|
||||
},
|
||||
)
|
||||
assert config.sidechain_ratio == 0.5
|
||||
assert config.sidechain_attack == 0.05
|
||||
assert config.sidechain_release == 0.8
|
||||
@@ -81,17 +86,20 @@ class TestBGMConfigFromConfigDict:
|
||||
|
||||
def test_all_params_custom(self):
|
||||
"""所有参数自定义."""
|
||||
config = BGMConfig.from_config_dict("/full.mp3", {
|
||||
"volume": 0.7,
|
||||
"fade_in": 1.5,
|
||||
"fade_out": 2.0,
|
||||
"loop_enabled": False,
|
||||
"sidechain_enabled": True,
|
||||
"sidechain_ratio": 0.4,
|
||||
"sidechain_attack": 0.03,
|
||||
"sidechain_release": 0.6,
|
||||
"sidechain_threshold": -20.0,
|
||||
})
|
||||
config = BGMConfig.from_config_dict(
|
||||
"/full.mp3",
|
||||
{
|
||||
"volume": 0.7,
|
||||
"fade_in": 1.5,
|
||||
"fade_out": 2.0,
|
||||
"loop_enabled": False,
|
||||
"sidechain_enabled": True,
|
||||
"sidechain_ratio": 0.4,
|
||||
"sidechain_attack": 0.03,
|
||||
"sidechain_release": 0.6,
|
||||
"sidechain_threshold": -20.0,
|
||||
},
|
||||
)
|
||||
assert config.volume == 0.7
|
||||
assert config.fade_in == 1.5
|
||||
assert config.fade_out == 2.0
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.chroma_key_engine import (
|
||||
CHROMA_KEY_PRESETS,
|
||||
ChromaKeyConfig,
|
||||
@@ -52,93 +51,115 @@ class TestChromaKeyConfigFromDict:
|
||||
|
||||
def test_custom_key_color(self):
|
||||
"""自定义抠像颜色."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"key_color": "#0000FF",
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"key_color": "#0000FF",
|
||||
}
|
||||
)
|
||||
assert config.key_color == "#0000FF"
|
||||
|
||||
def test_similarity_parsed(self):
|
||||
"""相似度解析."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"similarity": 0.5,
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"similarity": 0.5,
|
||||
}
|
||||
)
|
||||
assert config.similarity == 0.5
|
||||
|
||||
def test_similarity_clamped_min(self):
|
||||
"""相似度下限钳制."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"similarity": 0.001,
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"similarity": 0.001,
|
||||
}
|
||||
)
|
||||
assert config.similarity == 0.01
|
||||
|
||||
def test_similarity_clamped_max(self):
|
||||
"""相似度上限钳制."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"similarity": 2.0,
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"similarity": 2.0,
|
||||
}
|
||||
)
|
||||
assert config.similarity == 1.0
|
||||
|
||||
def test_blend_clamped_min(self):
|
||||
"""混合度下限钳制."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"blend": -0.5,
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"blend": -0.5,
|
||||
}
|
||||
)
|
||||
assert config.blend == 0.0
|
||||
|
||||
def test_blend_clamped_max(self):
|
||||
"""混合度上限钳制."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"blend": 1.5,
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"blend": 1.5,
|
||||
}
|
||||
)
|
||||
assert config.blend == 1.0
|
||||
|
||||
def test_spill_suppress_clamped(self):
|
||||
"""溢色抑制钳制."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"spill_suppress": 2.0,
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"spill_suppress": 2.0,
|
||||
}
|
||||
)
|
||||
assert config.spill_suppress == 1.0
|
||||
|
||||
def test_invalid_similarity_falls_back(self):
|
||||
"""无效相似度回退到默认."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"similarity": "not_a_number",
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"similarity": "not_a_number",
|
||||
}
|
||||
)
|
||||
assert config.similarity == 0.3
|
||||
|
||||
def test_invalid_blend_falls_back(self):
|
||||
"""无效混合度回退."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"blend": "high",
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"blend": "high",
|
||||
}
|
||||
)
|
||||
assert config.blend == 0.1
|
||||
|
||||
def test_key_color_stripped(self):
|
||||
"""颜色值去除首尾空格."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"key_color": " #FF0000 ",
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"key_color": " #FF0000 ",
|
||||
}
|
||||
)
|
||||
assert config.key_color == "#FF0000"
|
||||
|
||||
def test_all_params_custom(self):
|
||||
"""所有参数自定义."""
|
||||
config = ChromaKeyConfig.from_dict({
|
||||
"enabled": True,
|
||||
"key_color": "#0000FF",
|
||||
"similarity": 0.45,
|
||||
"blend": 0.15,
|
||||
"spill_suppress": 0.6,
|
||||
})
|
||||
config = ChromaKeyConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"key_color": "#0000FF",
|
||||
"similarity": 0.45,
|
||||
"blend": 0.15,
|
||||
"spill_suppress": 0.6,
|
||||
}
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.key_color == "#0000FF"
|
||||
assert config.similarity == 0.45
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.color_grade_engine import (
|
||||
DEFAULT_PARAMS,
|
||||
PARAM_RANGES,
|
||||
@@ -55,39 +54,47 @@ class TestColorGradeConfigFromDict:
|
||||
|
||||
def test_with_preset(self):
|
||||
"""指定预设."""
|
||||
config = ColorGradeConfig.from_dict({
|
||||
"enabled": True,
|
||||
"preset": "fresh",
|
||||
})
|
||||
config = ColorGradeConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"preset": "fresh",
|
||||
}
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.preset == "fresh"
|
||||
|
||||
def test_invalid_preset_ignored(self):
|
||||
"""无效预设被忽略."""
|
||||
config = ColorGradeConfig.from_dict({
|
||||
"enabled": True,
|
||||
"preset": "unknown_preset",
|
||||
})
|
||||
config = ColorGradeConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"preset": "unknown_preset",
|
||||
}
|
||||
)
|
||||
assert config.preset == ""
|
||||
|
||||
def test_custom_brightness(self):
|
||||
"""自定义亮度."""
|
||||
config = ColorGradeConfig.from_dict({
|
||||
"enabled": True,
|
||||
"brightness": 20,
|
||||
})
|
||||
config = ColorGradeConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"brightness": 20,
|
||||
}
|
||||
)
|
||||
assert config.brightness == 20.0
|
||||
|
||||
def test_custom_all_params(self):
|
||||
"""所有参数自定义."""
|
||||
config = ColorGradeConfig.from_dict({
|
||||
"enabled": True,
|
||||
"brightness": 10,
|
||||
"contrast": 15,
|
||||
"saturation": 120,
|
||||
"temperature": -5,
|
||||
"hue": 10,
|
||||
})
|
||||
config = ColorGradeConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"brightness": 10,
|
||||
"contrast": 15,
|
||||
"saturation": 120,
|
||||
"temperature": -5,
|
||||
"hue": 10,
|
||||
}
|
||||
)
|
||||
assert config.brightness == 10.0
|
||||
assert config.contrast == 15.0
|
||||
assert config.saturation == 120.0
|
||||
@@ -96,27 +103,33 @@ class TestColorGradeConfigFromDict:
|
||||
|
||||
def test_invalid_param_value_returns_none(self):
|
||||
"""无效参数值返回None(不覆盖)."""
|
||||
config = ColorGradeConfig.from_dict({
|
||||
"enabled": True,
|
||||
"brightness": "not_a_number",
|
||||
})
|
||||
config = ColorGradeConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"brightness": "not_a_number",
|
||||
}
|
||||
)
|
||||
assert config.brightness is None
|
||||
|
||||
def test_null_param_returns_none(self):
|
||||
"""null参数值返回None."""
|
||||
config = ColorGradeConfig.from_dict({
|
||||
"enabled": True,
|
||||
"contrast": None,
|
||||
})
|
||||
config = ColorGradeConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"contrast": None,
|
||||
}
|
||||
)
|
||||
assert config.contrast is None
|
||||
|
||||
def test_preset_with_custom_override(self):
|
||||
"""预设 + 自定义覆盖."""
|
||||
config = ColorGradeConfig.from_dict({
|
||||
"enabled": True,
|
||||
"preset": "vintage",
|
||||
"brightness": 5,
|
||||
})
|
||||
config = ColorGradeConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"preset": "vintage",
|
||||
"brightness": 5,
|
||||
}
|
||||
)
|
||||
assert config.preset == "vintage"
|
||||
assert config.brightness == 5.0
|
||||
|
||||
@@ -192,9 +205,7 @@ class TestResolveParams:
|
||||
"""返回所有5个参数."""
|
||||
config = ColorGradeConfig(enabled=True)
|
||||
params = config.resolve_params()
|
||||
assert set(params.keys()) == {
|
||||
"brightness", "contrast", "saturation", "temperature", "hue"
|
||||
}
|
||||
assert set(params.keys()) == {"brightness", "contrast", "saturation", "temperature", "hue"}
|
||||
|
||||
|
||||
class TestHasEffect:
|
||||
@@ -247,6 +258,4 @@ class TestPresets:
|
||||
|
||||
def test_param_ranges_defined(self):
|
||||
"""参数范围定义完整."""
|
||||
assert set(PARAM_RANGES.keys()) == {
|
||||
"brightness", "contrast", "saturation", "temperature", "hue"
|
||||
}
|
||||
assert set(PARAM_RANGES.keys()) == {"brightness", "contrast", "saturation", "temperature", "hue"}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.concat_engine import ConcatConfig, ConcatSegment
|
||||
|
||||
|
||||
@@ -31,68 +30,84 @@ class TestConcatSegmentFromDict:
|
||||
|
||||
def test_custom_start_time(self):
|
||||
"""自定义开始时间."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"start_time": 5.0,
|
||||
})
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/a.mp4",
|
||||
"start_time": 5.0,
|
||||
}
|
||||
)
|
||||
assert seg.start_time == 5.0
|
||||
|
||||
def test_custom_duration(self):
|
||||
"""自定义时长."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"duration": 10.0,
|
||||
})
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/a.mp4",
|
||||
"duration": 10.0,
|
||||
}
|
||||
)
|
||||
assert seg.duration == 10.0
|
||||
|
||||
def test_start_time_negative_clamped(self):
|
||||
"""负开始时间钳制到0."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"start_time": -5.0,
|
||||
})
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/a.mp4",
|
||||
"start_time": -5.0,
|
||||
}
|
||||
)
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_duration_negative_clamped(self):
|
||||
"""负时长钳制到0."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"duration": -3.0,
|
||||
})
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/a.mp4",
|
||||
"duration": -3.0,
|
||||
}
|
||||
)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_invalid_start_time_falls_back(self):
|
||||
"""无效start_time回退到0."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"start_time": "invalid",
|
||||
})
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/a.mp4",
|
||||
"start_time": "invalid",
|
||||
}
|
||||
)
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_invalid_duration_falls_back(self):
|
||||
"""无效duration回退到0."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"duration": "not_a_number",
|
||||
})
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/a.mp4",
|
||||
"duration": "not_a_number",
|
||||
}
|
||||
)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_no_audio(self):
|
||||
"""无音频."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/a.mp4",
|
||||
"has_audio": False,
|
||||
})
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/a.mp4",
|
||||
"has_audio": False,
|
||||
}
|
||||
)
|
||||
assert seg.has_audio is False
|
||||
|
||||
def test_full_config(self):
|
||||
"""完整配置."""
|
||||
seg = ConcatSegment.from_dict({
|
||||
"video_path": "/video.mp4",
|
||||
"start_time": 2.5,
|
||||
"duration": 15.0,
|
||||
"has_audio": False,
|
||||
})
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "/video.mp4",
|
||||
"start_time": 2.5,
|
||||
"duration": 15.0,
|
||||
"has_audio": False,
|
||||
}
|
||||
)
|
||||
assert seg.video_path == "/video.mp4"
|
||||
assert seg.start_time == 2.5
|
||||
assert seg.duration == 15.0
|
||||
@@ -129,96 +144,116 @@ class TestConcatConfigFromConfigDict:
|
||||
|
||||
def test_single_segment(self):
|
||||
"""单片段."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
})
|
||||
config = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
}
|
||||
)
|
||||
assert len(config.segments) == 1
|
||||
assert config.segments[0].video_path == "/a.mp4"
|
||||
|
||||
def test_multiple_segments(self):
|
||||
"""多片段."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4", "start_time": 1.0},
|
||||
{"video_path": "/b.mp4", "duration": 5.0},
|
||||
{"video_path": "/c.mp4"},
|
||||
],
|
||||
})
|
||||
config = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4", "start_time": 1.0},
|
||||
{"video_path": "/b.mp4", "duration": 5.0},
|
||||
{"video_path": "/c.mp4"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(config.segments) == 3
|
||||
assert config.segments[0].start_time == 1.0
|
||||
assert config.segments[1].duration == 5.0
|
||||
|
||||
def test_skips_no_path(self):
|
||||
"""跳过无video_path的片段."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"other": "value"},
|
||||
{"video_path": ""},
|
||||
],
|
||||
})
|
||||
config = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"other": "value"},
|
||||
{"video_path": ""},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(config.segments) == 1
|
||||
|
||||
def test_segments_not_list_ignored(self):
|
||||
"""segments不是列表忽略."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": "not_a_list",
|
||||
})
|
||||
config = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": "not_a_list",
|
||||
}
|
||||
)
|
||||
assert config.segments == []
|
||||
|
||||
def test_output_size(self):
|
||||
"""输出尺寸."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
})
|
||||
config = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
}
|
||||
)
|
||||
assert config.output_width == 1920
|
||||
assert config.output_height == 1080
|
||||
|
||||
def test_negative_output_size_clamped(self):
|
||||
"""负输出尺寸钳制到0."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": -100,
|
||||
"output_height": -50,
|
||||
})
|
||||
config = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": -100,
|
||||
"output_height": -50,
|
||||
}
|
||||
)
|
||||
assert config.output_width == 0
|
||||
assert config.output_height == 0
|
||||
|
||||
def test_invalid_output_size_falls_back(self):
|
||||
"""无效输出尺寸回退."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": "wide",
|
||||
"output_fps": "sixty",
|
||||
})
|
||||
config = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": "wide",
|
||||
"output_fps": "sixty",
|
||||
}
|
||||
)
|
||||
assert config.output_width == 0
|
||||
assert config.output_fps == 0.0
|
||||
|
||||
def test_output_fps(self):
|
||||
"""输出帧率."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_fps": 60.0,
|
||||
})
|
||||
config = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_fps": 60.0,
|
||||
}
|
||||
)
|
||||
assert config.output_fps == 60.0
|
||||
|
||||
def test_force_reencode(self):
|
||||
"""强制重新编码."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"force_reencode": True,
|
||||
})
|
||||
config = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"force_reencode": True,
|
||||
}
|
||||
)
|
||||
assert config.force_reencode is True
|
||||
|
||||
def test_transition_config(self):
|
||||
"""转场配置."""
|
||||
config = ConcatConfig.from_config_dict({
|
||||
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
|
||||
"transition": "crossfade",
|
||||
"transition_duration": 1.0,
|
||||
})
|
||||
config = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
|
||||
"transition": "crossfade",
|
||||
"transition_duration": 1.0,
|
||||
}
|
||||
)
|
||||
assert config.transition == "crossfade"
|
||||
assert config.transition_duration == 1.0
|
||||
|
||||
@@ -238,17 +273,21 @@ class TestHasEffect:
|
||||
|
||||
def test_one_segment_no_effect(self):
|
||||
"""单片段无效果(拼接至少需要2段)."""
|
||||
config = ConcatConfig(segments=[
|
||||
ConcatSegment(video_path="/a.mp4"),
|
||||
])
|
||||
config = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="/a.mp4"),
|
||||
]
|
||||
)
|
||||
assert config.has_effect is False
|
||||
|
||||
def test_two_segments_has_effect(self):
|
||||
"""两段及以上有效果."""
|
||||
config = ConcatConfig(segments=[
|
||||
ConcatSegment(video_path="/a.mp4"),
|
||||
ConcatSegment(video_path="/b.mp4"),
|
||||
])
|
||||
config = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="/a.mp4"),
|
||||
ConcatSegment(video_path="/b.mp4"),
|
||||
]
|
||||
)
|
||||
assert config.has_effect is True
|
||||
|
||||
|
||||
@@ -262,9 +301,11 @@ class TestTotalSegments:
|
||||
|
||||
def test_three_segments(self):
|
||||
"""三个片段."""
|
||||
config = ConcatConfig(segments=[
|
||||
ConcatSegment(video_path="/a.mp4"),
|
||||
ConcatSegment(video_path="/b.mp4"),
|
||||
ConcatSegment(video_path="/c.mp4"),
|
||||
])
|
||||
config = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="/a.mp4"),
|
||||
ConcatSegment(video_path="/b.mp4"),
|
||||
ConcatSegment(video_path="/c.mp4"),
|
||||
]
|
||||
)
|
||||
assert config.total_segments == 3
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.ffmpeg_utils import (
|
||||
XFADE_TRANSITION_MAP,
|
||||
build_xfade_filter_chain,
|
||||
chain_filters,
|
||||
resolve_xfade_transition,
|
||||
build_xfade_filter_chain,
|
||||
)
|
||||
|
||||
|
||||
@@ -97,9 +96,7 @@ class TestBuildXfadeFilterChain:
|
||||
|
||||
def test_single_clip(self):
|
||||
"""1个片段→直接copy,总时长等于片段时长."""
|
||||
filter_str, total_dur = build_xfade_filter_chain(
|
||||
[10.0], ["v0"], [], output_label="outv"
|
||||
)
|
||||
filter_str, total_dur = build_xfade_filter_chain([10.0], ["v0"], [], output_label="outv")
|
||||
assert "[v0]copy[outv]" in filter_str
|
||||
assert total_dur == pytest.approx(10.0)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.intro_outro_engine import IntroOutroConfig
|
||||
|
||||
|
||||
|
||||
@@ -229,10 +229,12 @@ class TestModuleRegistryCapabilities:
|
||||
def test_has_capability_true(self):
|
||||
"""检查已存在的能力."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(
|
||||
name="ai_mod",
|
||||
capabilities=[ModuleCapability(name="generate_voice")],
|
||||
))
|
||||
registry.register(
|
||||
Module(
|
||||
name="ai_mod",
|
||||
capabilities=[ModuleCapability(name="generate_voice")],
|
||||
)
|
||||
)
|
||||
assert registry.has_capability("generate_voice") is True
|
||||
|
||||
def test_has_capability_false(self):
|
||||
@@ -270,10 +272,12 @@ class TestModuleRegistryCapabilities:
|
||||
def test_get_quota_rules_empty(self):
|
||||
"""没有配额规则时返回空列表."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(
|
||||
name="m1",
|
||||
capabilities=[ModuleCapability(name="do_something")],
|
||||
))
|
||||
registry.register(
|
||||
Module(
|
||||
name="m1",
|
||||
capabilities=[ModuleCapability(name="do_something")],
|
||||
)
|
||||
)
|
||||
rules = registry.get_quota_rules("do_something")
|
||||
assert rules == []
|
||||
|
||||
@@ -281,10 +285,12 @@ class TestModuleRegistryCapabilities:
|
||||
"""获取配额规则."""
|
||||
registry = ModuleRegistry()
|
||||
rules = [QuotaRule("credits", 2.0)]
|
||||
registry.register(Module(
|
||||
name="m1",
|
||||
capabilities=[ModuleCapability(name="do_something", quota_rules=rules)],
|
||||
))
|
||||
registry.register(
|
||||
Module(
|
||||
name="m1",
|
||||
capabilities=[ModuleCapability(name="do_something", quota_rules=rules)],
|
||||
)
|
||||
)
|
||||
result = registry.get_quota_rules("do_something")
|
||||
assert len(result) == 1
|
||||
assert result[0].dimension == "credits"
|
||||
@@ -293,17 +299,21 @@ class TestModuleRegistryCapabilities:
|
||||
def test_get_active_capabilities(self):
|
||||
"""获取所有已激活模块的能力."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(
|
||||
name="mod_a",
|
||||
capabilities=[
|
||||
ModuleCapability(name="cap_a1"),
|
||||
ModuleCapability(name="cap_a2"),
|
||||
],
|
||||
))
|
||||
registry.register(Module(
|
||||
name="mod_b",
|
||||
capabilities=[ModuleCapability(name="cap_b1")],
|
||||
))
|
||||
registry.register(
|
||||
Module(
|
||||
name="mod_a",
|
||||
capabilities=[
|
||||
ModuleCapability(name="cap_a1"),
|
||||
ModuleCapability(name="cap_a2"),
|
||||
],
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
Module(
|
||||
name="mod_b",
|
||||
capabilities=[ModuleCapability(name="cap_b1")],
|
||||
)
|
||||
)
|
||||
result = registry.get_active_capabilities()
|
||||
assert "mod_a" in result
|
||||
assert "mod_b" in result
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.multi_track_mixer import (
|
||||
DEFAULT_VOLUMES,
|
||||
MAX_AUDIO_TRACKS,
|
||||
@@ -64,135 +63,163 @@ class TestAudioTrackFromDict:
|
||||
|
||||
def test_basic_parsing(self):
|
||||
"""基本解析."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/bgm.mp3",
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/bgm.mp3",
|
||||
}
|
||||
)
|
||||
assert track.track_id == "t1"
|
||||
assert track.track_type == "bgm"
|
||||
assert track.audio_path == "/bgm.mp3"
|
||||
|
||||
def test_default_volume_by_type_bgm(self):
|
||||
"""bgm默认音量0.3."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/a.mp3",
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/a.mp3",
|
||||
}
|
||||
)
|
||||
assert track.volume == 0.3
|
||||
|
||||
def test_default_volume_by_type_sfx(self):
|
||||
"""sfx默认音量0.7."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"track_type": "sfx",
|
||||
"audio_path": "/a.mp3",
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "sfx",
|
||||
"audio_path": "/a.mp3",
|
||||
}
|
||||
)
|
||||
assert track.volume == 0.7
|
||||
|
||||
def test_default_volume_unknown_type(self):
|
||||
"""未知类型默认音量1.0."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"track_type": "unknown_type",
|
||||
"audio_path": "/a.mp3",
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "unknown_type",
|
||||
"audio_path": "/a.mp3",
|
||||
}
|
||||
)
|
||||
assert track.volume == 1.0
|
||||
|
||||
def test_custom_volume(self):
|
||||
"""自定义音量."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": 0.5,
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": 0.5,
|
||||
}
|
||||
)
|
||||
assert track.volume == 0.5
|
||||
|
||||
def test_volume_clamped_high(self):
|
||||
"""音量上限钳制."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": 3.0,
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": 3.0,
|
||||
}
|
||||
)
|
||||
assert track.volume == 2.0
|
||||
|
||||
def test_volume_clamped_low(self):
|
||||
"""音量下限钳制."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": -1.0,
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": -1.0,
|
||||
}
|
||||
)
|
||||
assert track.volume == 0.0
|
||||
|
||||
def test_volume_invalid_falls_back(self):
|
||||
"""无效音量回退到类型默认值."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": "not_a_number",
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": "not_a_number",
|
||||
}
|
||||
)
|
||||
assert track.volume == 0.3
|
||||
|
||||
def test_fade_in(self):
|
||||
"""淡入时长."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"fade_in": 2.5,
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"fade_in": 2.5,
|
||||
}
|
||||
)
|
||||
assert track.fade_in == 2.5
|
||||
|
||||
def test_fade_negative_clamped(self):
|
||||
"""负淡入钳制到0."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"fade_in": -1.0,
|
||||
"fade_out": -2.0,
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"fade_in": -1.0,
|
||||
"fade_out": -2.0,
|
||||
}
|
||||
)
|
||||
assert track.fade_in == 0.0
|
||||
assert track.fade_out == 0.0
|
||||
|
||||
def test_start_time(self):
|
||||
"""开始时间."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"start_time": 5.5,
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"start_time": 5.5,
|
||||
}
|
||||
)
|
||||
assert track.start_time == 5.5
|
||||
|
||||
def test_start_time_negative_clamped(self):
|
||||
"""负开始时间钳制到0."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"start_time": -3.0,
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"start_time": -3.0,
|
||||
}
|
||||
)
|
||||
assert track.start_time == 0.0
|
||||
|
||||
def test_disabled_track(self):
|
||||
"""禁用轨道."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"enabled": False,
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"enabled": False,
|
||||
}
|
||||
)
|
||||
assert track.enabled is False
|
||||
|
||||
def test_invalid_fade_in_falls_back(self):
|
||||
"""无效淡入值回退到0."""
|
||||
track = AudioTrack.from_dict({
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"fade_in": "fast",
|
||||
})
|
||||
track = AudioTrack.from_dict(
|
||||
{
|
||||
"track_id": "t1",
|
||||
"audio_path": "/a.mp3",
|
||||
"fade_in": "fast",
|
||||
}
|
||||
)
|
||||
assert track.fade_in == 0.0
|
||||
|
||||
|
||||
@@ -224,89 +251,107 @@ class TestMultiTrackMixConfigFromConfigDict:
|
||||
|
||||
def test_single_track(self):
|
||||
"""单轨道."""
|
||||
config = MultiTrackMixConfig.from_config_dict({
|
||||
"tracks": [
|
||||
{
|
||||
"track_id": "bgm1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/bgm.mp3",
|
||||
},
|
||||
],
|
||||
})
|
||||
config = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{
|
||||
"track_id": "bgm1",
|
||||
"track_type": "bgm",
|
||||
"audio_path": "/bgm.mp3",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(config.tracks) == 1
|
||||
assert config.tracks[0].track_id == "bgm1"
|
||||
|
||||
def test_multiple_tracks(self):
|
||||
"""多轨道."""
|
||||
config = MultiTrackMixConfig.from_config_dict({
|
||||
"tracks": [
|
||||
{"track_id": "t1", "track_type": "bgm", "audio_path": "/a.mp3"},
|
||||
{"track_id": "t2", "track_type": "sfx", "audio_path": "/b.mp3"},
|
||||
],
|
||||
})
|
||||
config = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "t1", "track_type": "bgm", "audio_path": "/a.mp3"},
|
||||
{"track_id": "t2", "track_type": "sfx", "audio_path": "/b.mp3"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(config.tracks) == 2
|
||||
|
||||
def test_skips_disabled_tracks(self):
|
||||
"""跳过禁用轨道."""
|
||||
config = MultiTrackMixConfig.from_config_dict({
|
||||
"tracks": [
|
||||
{"track_id": "t1", "audio_path": "/a.mp3", "enabled": True},
|
||||
{"track_id": "t2", "audio_path": "/b.mp3", "enabled": False},
|
||||
],
|
||||
})
|
||||
config = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "t1", "audio_path": "/a.mp3", "enabled": True},
|
||||
{"track_id": "t2", "audio_path": "/b.mp3", "enabled": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(config.tracks) == 1
|
||||
assert config.tracks[0].track_id == "t1"
|
||||
|
||||
def test_skips_no_audio_path(self):
|
||||
"""跳过无audio_path的轨道."""
|
||||
config = MultiTrackMixConfig.from_config_dict({
|
||||
"tracks": [
|
||||
{"track_id": "t1", "audio_path": "/a.mp3"},
|
||||
{"track_id": "t2", "audio_path": ""},
|
||||
{"track_id": "t3"},
|
||||
],
|
||||
})
|
||||
config = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "t1", "audio_path": "/a.mp3"},
|
||||
{"track_id": "t2", "audio_path": ""},
|
||||
{"track_id": "t3"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(config.tracks) == 1
|
||||
|
||||
def test_master_volume(self):
|
||||
"""主音量."""
|
||||
config = MultiTrackMixConfig.from_config_dict({
|
||||
"master_volume": 0.8,
|
||||
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
|
||||
})
|
||||
config = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"master_volume": 0.8,
|
||||
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
|
||||
}
|
||||
)
|
||||
assert config.master_volume == 0.8
|
||||
|
||||
def test_master_volume_clamped(self):
|
||||
"""主音量边界钳制."""
|
||||
config = MultiTrackMixConfig.from_config_dict({
|
||||
"master_volume": 5.0,
|
||||
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
|
||||
})
|
||||
config = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"master_volume": 5.0,
|
||||
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
|
||||
}
|
||||
)
|
||||
assert config.master_volume == 2.0
|
||||
|
||||
def test_normalize_disabled(self):
|
||||
"""禁用归一化."""
|
||||
config = MultiTrackMixConfig.from_config_dict({
|
||||
"normalize": False,
|
||||
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
|
||||
})
|
||||
config = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"normalize": False,
|
||||
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
|
||||
}
|
||||
)
|
||||
assert config.normalize is False
|
||||
|
||||
def test_tracks_not_list_ignored(self):
|
||||
"""tracks不是列表时忽略."""
|
||||
config = MultiTrackMixConfig.from_config_dict({
|
||||
"tracks": "not_a_list",
|
||||
})
|
||||
config = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": "not_a_list",
|
||||
}
|
||||
)
|
||||
assert config.tracks == []
|
||||
|
||||
def test_non_dict_track_skipped(self):
|
||||
"""非dict轨道跳过."""
|
||||
config = MultiTrackMixConfig.from_config_dict({
|
||||
"tracks": [
|
||||
{"track_id": "t1", "audio_path": "/a.mp3"},
|
||||
"not_a_dict",
|
||||
],
|
||||
})
|
||||
config = MultiTrackMixConfig.from_config_dict(
|
||||
{
|
||||
"tracks": [
|
||||
{"track_id": "t1", "audio_path": "/a.mp3"},
|
||||
"not_a_dict",
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(config.tracks) == 1
|
||||
|
||||
|
||||
@@ -320,14 +365,18 @@ class TestHasEffect:
|
||||
|
||||
def test_with_tracks_has_effect(self):
|
||||
"""有轨道有效果."""
|
||||
config = MultiTrackMixConfig(tracks=[
|
||||
AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3"),
|
||||
])
|
||||
config = MultiTrackMixConfig(
|
||||
tracks=[
|
||||
AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3"),
|
||||
]
|
||||
)
|
||||
assert config.has_effect is True
|
||||
|
||||
def test_disabled_tracks_no_effect(self):
|
||||
"""所有轨道都禁用无效果."""
|
||||
config = MultiTrackMixConfig(tracks=[
|
||||
AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3", enabled=False),
|
||||
])
|
||||
config = MultiTrackMixConfig(
|
||||
tracks=[
|
||||
AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3", enabled=False),
|
||||
]
|
||||
)
|
||||
assert config.has_effect is False
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.noise_reduction_engine import (
|
||||
NoiseReductionConfig,
|
||||
NoiseReductionLevel,
|
||||
@@ -96,64 +95,78 @@ class TestNoiseReductionConfigFromDict:
|
||||
|
||||
def test_noise_floor_parsed(self):
|
||||
"""噪音阈值解析."""
|
||||
config = NoiseReductionConfig.from_dict({
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": -30.0,
|
||||
})
|
||||
config = NoiseReductionConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": -30.0,
|
||||
}
|
||||
)
|
||||
assert config.noise_floor == -30.0
|
||||
|
||||
def test_noise_floor_clamped_min(self):
|
||||
"""噪音阈值下限钳制 (-60)."""
|
||||
config = NoiseReductionConfig.from_dict({
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": -100.0,
|
||||
})
|
||||
config = NoiseReductionConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": -100.0,
|
||||
}
|
||||
)
|
||||
assert config.noise_floor == -60.0
|
||||
|
||||
def test_noise_floor_clamped_max(self):
|
||||
"""噪音阈值上限钳制 (-5)."""
|
||||
config = NoiseReductionConfig.from_dict({
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": 0.0,
|
||||
})
|
||||
config = NoiseReductionConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": 0.0,
|
||||
}
|
||||
)
|
||||
assert config.noise_floor == -5.0
|
||||
|
||||
def test_noise_floor_boundary_low(self):
|
||||
"""噪音阈值边界值 -60."""
|
||||
config = NoiseReductionConfig.from_dict({
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": -60.0,
|
||||
})
|
||||
config = NoiseReductionConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": -60.0,
|
||||
}
|
||||
)
|
||||
assert config.noise_floor == -60.0
|
||||
|
||||
def test_noise_floor_boundary_high(self):
|
||||
"""噪音阈值边界值 -5."""
|
||||
config = NoiseReductionConfig.from_dict({
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": -5.0,
|
||||
})
|
||||
config = NoiseReductionConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": -5.0,
|
||||
}
|
||||
)
|
||||
assert config.noise_floor == -5.0
|
||||
|
||||
def test_invalid_noise_floor_falls_back(self):
|
||||
"""无效噪音阈值 fallback 到默认值."""
|
||||
config = NoiseReductionConfig.from_dict({
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": "not_a_number",
|
||||
})
|
||||
config = NoiseReductionConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"level": "custom",
|
||||
"noise_floor": "not_a_number",
|
||||
}
|
||||
)
|
||||
assert config.noise_floor == -25.0
|
||||
|
||||
def test_voice_enhance_enabled(self):
|
||||
"""人声增强启用."""
|
||||
config = NoiseReductionConfig.from_dict({
|
||||
"enabled": True,
|
||||
"voice_enhance": True,
|
||||
})
|
||||
config = NoiseReductionConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"voice_enhance": True,
|
||||
}
|
||||
)
|
||||
assert config.voice_enhance is True
|
||||
|
||||
def test_voice_enhance_disabled_default(self):
|
||||
|
||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.oss_helpers import normalize_storage_key, resolve_asset_path
|
||||
|
||||
|
||||
@@ -21,23 +20,17 @@ class TestNormalizeStorageKey:
|
||||
|
||||
def test_https_url_extracts_path(self):
|
||||
"""HTTPS URL提取path部分."""
|
||||
result = normalize_storage_key(
|
||||
"https://bucket.oss-cn-hangzhou.aliyuncs.com/path/to/file.mp4"
|
||||
)
|
||||
result = normalize_storage_key("https://bucket.oss-cn-hangzhou.aliyuncs.com/path/to/file.mp4")
|
||||
assert result == "path/to/file.mp4"
|
||||
|
||||
def test_http_url_extracts_path(self):
|
||||
"""HTTP URL提取path部分."""
|
||||
result = normalize_storage_key(
|
||||
"http://example.com/assets/video.mp4"
|
||||
)
|
||||
result = normalize_storage_key("http://example.com/assets/video.mp4")
|
||||
assert result == "assets/video.mp4"
|
||||
|
||||
def test_url_with_query_params(self):
|
||||
"""带query参数的URL只取path."""
|
||||
result = normalize_storage_key(
|
||||
"https://bucket.oss-cn-hangzhou.aliyuncs.com/file.mp4?token=abc&expires=123"
|
||||
)
|
||||
result = normalize_storage_key("https://bucket.oss-cn-hangzhou.aliyuncs.com/file.mp4?token=abc&expires=123")
|
||||
assert result == "file.mp4"
|
||||
|
||||
def test_leading_slash_stripped(self):
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.pip_engine import PiPConfig, PiPLayerConfig
|
||||
|
||||
|
||||
@@ -178,12 +177,14 @@ class TestPiPConfigFromDict:
|
||||
|
||||
def test_single_layer(self):
|
||||
"""单个图层."""
|
||||
config = PiPConfig.from_dict({
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "asset_001", "position": "top_left"},
|
||||
],
|
||||
})
|
||||
config = PiPConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "asset_001", "position": "top_left"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert len(config.layers) == 1
|
||||
assert config.layers[0].source == "asset_001"
|
||||
@@ -191,14 +192,16 @@ class TestPiPConfigFromDict:
|
||||
|
||||
def test_multiple_layers_sorted_by_z_index(self):
|
||||
"""多个图层按z_index排序."""
|
||||
config = PiPConfig.from_dict({
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "a", "z_index": 3},
|
||||
{"source": "b", "z_index": 1},
|
||||
{"source": "c", "z_index": 2},
|
||||
],
|
||||
})
|
||||
config = PiPConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "a", "z_index": 3},
|
||||
{"source": "b", "z_index": 1},
|
||||
{"source": "c", "z_index": 2},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(config.layers) == 3
|
||||
assert config.layers[0].z_index == 1
|
||||
assert config.layers[1].z_index == 2
|
||||
@@ -206,48 +209,54 @@ class TestPiPConfigFromDict:
|
||||
|
||||
def test_invalid_layer_skipped(self):
|
||||
"""无效图层跳过."""
|
||||
config = PiPConfig.from_dict({
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "valid_asset"},
|
||||
{"source": ""}, # 无效,空source
|
||||
],
|
||||
})
|
||||
config = PiPConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": "valid_asset"},
|
||||
{"source": ""}, # 无效,空source
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(config.layers) == 1
|
||||
assert config.layers[0].source == "valid_asset"
|
||||
|
||||
def test_all_invalid_layers_disabled(self):
|
||||
"""全部无效则disabled."""
|
||||
config = PiPConfig.from_dict({
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": ""},
|
||||
{"source": ""},
|
||||
],
|
||||
})
|
||||
config = PiPConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{"source": ""},
|
||||
{"source": ""},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert config.enabled is False
|
||||
assert config.layers == []
|
||||
|
||||
def test_layer_full_config(self):
|
||||
"""完整图层配置."""
|
||||
config = PiPConfig.from_dict({
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{
|
||||
"source": "https://example.com/video.mp4",
|
||||
"source_type": "url",
|
||||
"position": "bottom_right",
|
||||
"width": "30%",
|
||||
"opacity": 0.8,
|
||||
"corner_radius": 10,
|
||||
"border_width": 2,
|
||||
"border_color": "red",
|
||||
"start_time": 5.0,
|
||||
"duration": 10.0,
|
||||
"z_index": 5,
|
||||
},
|
||||
],
|
||||
})
|
||||
config = PiPConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"layers": [
|
||||
{
|
||||
"source": "https://example.com/video.mp4",
|
||||
"source_type": "url",
|
||||
"position": "bottom_right",
|
||||
"width": "30%",
|
||||
"opacity": 0.8,
|
||||
"corner_radius": 10,
|
||||
"border_width": 2,
|
||||
"border_color": "red",
|
||||
"start_time": 5.0,
|
||||
"duration": 10.0,
|
||||
"z_index": 5,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(config.layers) == 1
|
||||
layer = config.layers[0]
|
||||
assert layer.source == "https://example.com/video.mp4"
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.render_adapter import (
|
||||
DEFAULT_OUTPUT_HEIGHT,
|
||||
DEFAULT_OUTPUT_WIDTH,
|
||||
|
||||
@@ -7,7 +7,6 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.render_audio import (
|
||||
RenderContext,
|
||||
clip_effective_duration,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.speed_engine import (
|
||||
MAX_SPEED,
|
||||
MIN_SPEED,
|
||||
@@ -11,7 +10,6 @@ from video_processing.speed_engine import (
|
||||
SpeedEngine,
|
||||
)
|
||||
|
||||
|
||||
# ── 常量测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.sticker_engine import (
|
||||
ImageStickerConfig,
|
||||
TextStickerConfig,
|
||||
@@ -95,21 +94,25 @@ class TestParseStickersFromConfig:
|
||||
|
||||
def test_single_sticker(self):
|
||||
"""单个贴纸."""
|
||||
result = parse_stickers_from_config({
|
||||
"stickers": [{"type": "text", "text": "hello"}],
|
||||
})
|
||||
result = parse_stickers_from_config(
|
||||
{
|
||||
"stickers": [{"type": "text", "text": "hello"}],
|
||||
}
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["text"] == "hello"
|
||||
|
||||
def test_multiple_stickers(self):
|
||||
"""多个贴纸."""
|
||||
result = parse_stickers_from_config({
|
||||
"stickers": [
|
||||
{"type": "text", "text": "a"},
|
||||
{"type": "image", "image_url": "/b.png"},
|
||||
{"type": "text", "text": "c"},
|
||||
],
|
||||
})
|
||||
result = parse_stickers_from_config(
|
||||
{
|
||||
"stickers": [
|
||||
{"type": "text", "text": "a"},
|
||||
{"type": "image", "image_url": "/b.png"},
|
||||
{"type": "text", "text": "c"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(result) == 3
|
||||
|
||||
def test_returns_raw_dicts(self):
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.subtitle_render_engine import (
|
||||
SubtitleStyle,
|
||||
_escape_ass_text,
|
||||
@@ -14,7 +13,6 @@ from video_processing.subtitle_render_engine import (
|
||||
_wrap_text,
|
||||
)
|
||||
|
||||
|
||||
# ── 颜色转换测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -269,10 +267,12 @@ class TestSubtitleStyleFromDict:
|
||||
|
||||
def test_background_opacity_clamped(self):
|
||||
"""背景透明度钳制."""
|
||||
style = SubtitleStyle.from_dict({
|
||||
"background_enabled": True,
|
||||
"background_opacity": 2.0,
|
||||
})
|
||||
style = SubtitleStyle.from_dict(
|
||||
{
|
||||
"background_enabled": True,
|
||||
"background_opacity": 2.0,
|
||||
}
|
||||
)
|
||||
assert style.background_opacity == 1.0
|
||||
|
||||
def test_invalid_position_falls_back(self):
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.routes.templates_editor._utils import (
|
||||
_clip_type_to_scene_label,
|
||||
_clip_value,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.thumbnail_generator import _format_seek_time
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.transition_engine import (
|
||||
CUT_TRANSITION,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.trim_engine import MIN_TRIM_DURATION, TrimConfig, TrimSegment
|
||||
|
||||
|
||||
@@ -20,11 +19,16 @@ class TestTrimConfigFromDict:
|
||||
|
||||
def test_all_zero_returns_none(self):
|
||||
"""全零返回None."""
|
||||
assert TrimConfig.from_dict({
|
||||
"start_time": 0,
|
||||
"end_time": 0,
|
||||
"duration": 0,
|
||||
}) is None
|
||||
assert (
|
||||
TrimConfig.from_dict(
|
||||
{
|
||||
"start_time": 0,
|
||||
"end_time": 0,
|
||||
"duration": 0,
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_start_only(self):
|
||||
"""只有start_time有效."""
|
||||
@@ -64,10 +68,12 @@ class TestTrimConfigFromDict:
|
||||
|
||||
def test_string_values_converted(self):
|
||||
"""字符串值会被转换."""
|
||||
config = TrimConfig.from_dict({
|
||||
"start_time": "5.0",
|
||||
"duration": "10.0",
|
||||
})
|
||||
config = TrimConfig.from_dict(
|
||||
{
|
||||
"start_time": "5.0",
|
||||
"duration": "10.0",
|
||||
}
|
||||
)
|
||||
assert config is not None
|
||||
assert config.start_time == 5.0
|
||||
assert config.duration == 10.0
|
||||
@@ -235,11 +241,14 @@ class TestTrimSegment:
|
||||
|
||||
def test_from_dict_basic(self):
|
||||
"""基本解析."""
|
||||
seg = TrimSegment.from_dict({
|
||||
"start_time": 5.0,
|
||||
"duration": 10.0,
|
||||
"segment_id": "seg1",
|
||||
}, default_order=0)
|
||||
seg = TrimSegment.from_dict(
|
||||
{
|
||||
"start_time": 5.0,
|
||||
"duration": 10.0,
|
||||
"segment_id": "seg1",
|
||||
},
|
||||
default_order=0,
|
||||
)
|
||||
assert seg.segment_id == "seg1"
|
||||
assert seg.trim.start_time == 5.0
|
||||
assert seg.trim.duration == 10.0
|
||||
@@ -247,11 +256,13 @@ class TestTrimSegment:
|
||||
|
||||
def test_from_dict_with_order(self):
|
||||
"""带order的解析."""
|
||||
seg = TrimSegment.from_dict({
|
||||
"start_time": 1.0,
|
||||
"end_time": 4.0,
|
||||
"order": 2,
|
||||
})
|
||||
seg = TrimSegment.from_dict(
|
||||
{
|
||||
"start_time": 1.0,
|
||||
"end_time": 4.0,
|
||||
"order": 2,
|
||||
}
|
||||
)
|
||||
assert seg.order == 2
|
||||
assert seg.trim.start_time == 1.0
|
||||
assert seg.trim.end_time == 4.0
|
||||
@@ -264,8 +275,11 @@ class TestTrimSegment:
|
||||
|
||||
def test_from_dict_empty_string_segment_id(self):
|
||||
"""空字符串segment_id走默认."""
|
||||
seg = TrimSegment.from_dict({
|
||||
"segment_id": "",
|
||||
"duration": 5.0,
|
||||
}, default_order=5)
|
||||
seg = TrimSegment.from_dict(
|
||||
{
|
||||
"segment_id": "",
|
||||
"duration": 5.0,
|
||||
},
|
||||
default_order=5,
|
||||
)
|
||||
assert seg.segment_id == "seg_5"
|
||||
|
||||
@@ -5,12 +5,11 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.unified_render_service import (
|
||||
RenderLayer,
|
||||
ResolvedClip,
|
||||
_LAYER_Z_INDEX,
|
||||
_PIP_SCALE,
|
||||
RenderLayer,
|
||||
ResolvedClip,
|
||||
_resolve_layer_role,
|
||||
)
|
||||
|
||||
@@ -159,9 +158,11 @@ class TestRenderLayer:
|
||||
def test_with_clips(self):
|
||||
"""带片段的图层."""
|
||||
clip = ResolvedClip(
|
||||
clip_id="c1", asset_id="a1",
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/t.mp4"),
|
||||
clip_type="main", order=0,
|
||||
clip_type="main",
|
||||
order=0,
|
||||
)
|
||||
layer = RenderLayer(role="overlay", clips=[clip], z_index=1)
|
||||
assert len(layer.clips) == 1
|
||||
|
||||
+118
-114
@@ -3,14 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.watermark_engine import (
|
||||
WATERMARK_POSITIONS,
|
||||
WatermarkConfig,
|
||||
WatermarkEngine,
|
||||
)
|
||||
|
||||
|
||||
# ── WatermarkConfig 测试 ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -52,11 +50,13 @@ class TestWatermarkConfigFromDict:
|
||||
|
||||
def test_text_mode_basic(self):
|
||||
"""文字水印基本配置."""
|
||||
config = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "测试水印",
|
||||
})
|
||||
config = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "测试水印",
|
||||
}
|
||||
)
|
||||
assert config is not None
|
||||
assert config.mode == "text"
|
||||
assert config.text == "测试水印"
|
||||
@@ -64,86 +64,102 @@ class TestWatermarkConfigFromDict:
|
||||
|
||||
def test_text_mode_missing_text_returns_none(self):
|
||||
"""文字水印缺少 text 返回 None."""
|
||||
result = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
})
|
||||
result = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_text_mode_empty_text_returns_none(self):
|
||||
"""文字水印 text 为空返回 None."""
|
||||
result = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "",
|
||||
})
|
||||
result = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "",
|
||||
}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_image_mode_basic(self):
|
||||
"""图片水印基本配置."""
|
||||
config = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
"image_path": "/path/to/logo.png",
|
||||
})
|
||||
config = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
"image_path": "/path/to/logo.png",
|
||||
}
|
||||
)
|
||||
assert config is not None
|
||||
assert config.mode == "image"
|
||||
assert config.image_path == "/path/to/logo.png"
|
||||
|
||||
def test_image_mode_missing_image_returns_none(self):
|
||||
"""图片水印缺少 image_path 返回 None."""
|
||||
result = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
})
|
||||
result = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_image_mode_image_alias(self):
|
||||
"""image 字段作为 image_path 的别名."""
|
||||
config = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
"image": "/path/alias.png",
|
||||
})
|
||||
config = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
"image": "/path/alias.png",
|
||||
}
|
||||
)
|
||||
assert config is not None
|
||||
assert config.image_path == "/path/alias.png"
|
||||
|
||||
def test_invalid_position_falls_back(self):
|
||||
"""无效位置 fallback 到 bottom_right."""
|
||||
config = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "test",
|
||||
"position": "invalid_pos",
|
||||
})
|
||||
config = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "test",
|
||||
"position": "invalid_pos",
|
||||
}
|
||||
)
|
||||
assert config is not None
|
||||
assert config.position == "bottom_right"
|
||||
|
||||
def test_custom_position_valid(self):
|
||||
"""自定义有效位置."""
|
||||
config = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "test",
|
||||
"position": "top_left",
|
||||
})
|
||||
config = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "test",
|
||||
"position": "top_left",
|
||||
}
|
||||
)
|
||||
assert config is not None
|
||||
assert config.position == "top_left"
|
||||
|
||||
def test_all_text_fields_parsed(self):
|
||||
"""文字水印所有字段正确解析."""
|
||||
config = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "我的水印",
|
||||
"font_size": 32,
|
||||
"font_color": "red",
|
||||
"font_path": "/fonts/msyh.ttf",
|
||||
"position": "top_center",
|
||||
"opacity": 0.5,
|
||||
"margin_x": 30,
|
||||
"margin_y": 40,
|
||||
})
|
||||
config = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "我的水印",
|
||||
"font_size": 32,
|
||||
"font_color": "red",
|
||||
"font_path": "/fonts/msyh.ttf",
|
||||
"position": "top_center",
|
||||
"opacity": 0.5,
|
||||
"margin_x": 30,
|
||||
"margin_y": 40,
|
||||
}
|
||||
)
|
||||
assert config is not None
|
||||
assert config.text == "我的水印"
|
||||
assert config.font_size == 32
|
||||
@@ -156,16 +172,18 @@ class TestWatermarkConfigFromDict:
|
||||
|
||||
def test_all_image_fields_parsed(self):
|
||||
"""图片水印所有字段正确解析."""
|
||||
config = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
"image_path": "/img/logo.png",
|
||||
"scale": 0.3,
|
||||
"opacity": 0.9,
|
||||
"position": "bottom_left",
|
||||
"margin_x": 10,
|
||||
"margin_y": 15,
|
||||
})
|
||||
config = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
"image_path": "/img/logo.png",
|
||||
"scale": 0.3,
|
||||
"opacity": 0.9,
|
||||
"position": "bottom_left",
|
||||
"margin_x": 10,
|
||||
"margin_y": 15,
|
||||
}
|
||||
)
|
||||
assert config is not None
|
||||
assert config.image_path == "/img/logo.png"
|
||||
assert config.scale == 0.3
|
||||
@@ -174,23 +192,27 @@ class TestWatermarkConfigFromDict:
|
||||
|
||||
def test_scroll_config_parsed(self):
|
||||
"""滚动水印配置解析."""
|
||||
config = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "滚动水印",
|
||||
"scroll": True,
|
||||
"scroll_speed": 80,
|
||||
})
|
||||
config = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "滚动水印",
|
||||
"scroll": True,
|
||||
"scroll_speed": 80,
|
||||
}
|
||||
)
|
||||
assert config is not None
|
||||
assert config.scroll is True
|
||||
assert config.scroll_speed == 80
|
||||
|
||||
def test_default_mode_is_text(self):
|
||||
"""不传 mode 默认为 text."""
|
||||
config = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"text": "默认模式",
|
||||
})
|
||||
config = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"text": "默认模式",
|
||||
}
|
||||
)
|
||||
assert config is not None
|
||||
assert config.mode == "text"
|
||||
|
||||
@@ -325,95 +347,71 @@ class TestCalcPosition:
|
||||
|
||||
def test_top_left(self):
|
||||
"""左上角."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"top_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("top_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
|
||||
assert (x, y) == (20, 20)
|
||||
|
||||
def test_top_center(self):
|
||||
"""中上."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"top_center", self.W, self.H, self.WW, self.WH, self.MX, self.MY
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("top_center", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
|
||||
assert x == (1920 - 200) // 2
|
||||
assert y == 20
|
||||
|
||||
def test_top_right(self):
|
||||
"""右上角."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"top_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("top_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
|
||||
assert x == 1920 - 200 - 20
|
||||
assert y == 20
|
||||
|
||||
def test_center_left(self):
|
||||
"""左中."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"center_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("center_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
|
||||
assert x == 20
|
||||
assert y == (1080 - 100) // 2
|
||||
|
||||
def test_center(self):
|
||||
"""中心."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"center", self.W, self.H, self.WW, self.WH, self.MX, self.MY
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("center", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
|
||||
assert x == (1920 - 200) // 2
|
||||
assert y == (1080 - 100) // 2
|
||||
|
||||
def test_center_right(self):
|
||||
"""右中."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"center_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("center_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
|
||||
assert x == 1920 - 200 - 20
|
||||
assert y == (1080 - 100) // 2
|
||||
|
||||
def test_bottom_left(self):
|
||||
"""左下角."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"bottom_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("bottom_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
|
||||
assert x == 20
|
||||
assert y == 1080 - 100 - 20
|
||||
|
||||
def test_bottom_center(self):
|
||||
"""中下."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"bottom_center", self.W, self.H, self.WW, self.WH, self.MX, self.MY
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("bottom_center", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
|
||||
assert x == (1920 - 200) // 2
|
||||
assert y == 1080 - 100 - 20
|
||||
|
||||
def test_bottom_right(self):
|
||||
"""右下角."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"bottom_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("bottom_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
|
||||
assert x == 1920 - 200 - 20
|
||||
assert y == 1080 - 100 - 20
|
||||
|
||||
def test_unknown_position_defaults_bottom_right(self):
|
||||
"""未知位置默认右下角."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"unknown", self.W, self.H, self.WW, self.WH, self.MX, self.MY
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("unknown", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
|
||||
assert x == 1920 - 200 - 20
|
||||
assert y == 1080 - 100 - 20
|
||||
|
||||
def test_zero_margin(self):
|
||||
"""零边距."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"top_left", 1000, 500, 100, 50, 0, 0
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("top_left", 1000, 500, 100, 50, 0, 0)
|
||||
assert (x, y) == (0, 0)
|
||||
|
||||
def test_small_output(self):
|
||||
"""小尺寸输出."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"bottom_right", 320, 240, 50, 30, 5, 5
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("bottom_right", 320, 240, 50, 30, 5, 5)
|
||||
assert x == 320 - 50 - 5
|
||||
assert y == 240 - 30 - 5
|
||||
|
||||
@@ -446,8 +444,14 @@ class TestWatermarkPositions:
|
||||
def test_all_position_keys_valid(self):
|
||||
"""所有位置键名正确."""
|
||||
expected = {
|
||||
"top_left", "top_center", "top_right",
|
||||
"center_left", "center", "center_right",
|
||||
"bottom_left", "bottom_center", "bottom_right",
|
||||
"top_left",
|
||||
"top_center",
|
||||
"top_right",
|
||||
"center_left",
|
||||
"center",
|
||||
"center_right",
|
||||
"bottom_left",
|
||||
"bottom_center",
|
||||
"bottom_right",
|
||||
}
|
||||
assert set(WATERMARK_POSITIONS.keys()) == expected
|
||||
|
||||
Reference in New Issue
Block a user