This commit is contained in:
2025-09-24 10:50:34 +08:00
parent c055a68592
commit ec1705769c
18 changed files with 348 additions and 330 deletions

15
.flake8 Normal file
View File

@@ -0,0 +1,15 @@
[flake8]
max-line-length = 88
ignore =
# Line too long - handled by black
E501,
# Line break before binary operator - handled by black
W503
exclude =
.git,
__pycache__,
.venv,
venv,
tests,
.claude,
.serena

16
Jenkinsfile vendored
View File

@@ -7,6 +7,8 @@ pipeline {
VENV_NAME = 'venv' VENV_NAME = 'venv'
TEST_REPORTS_DIR = 'test-reports' TEST_REPORTS_DIR = 'test-reports'
COVERAGE_DIR = 'coverage-reports' COVERAGE_DIR = 'coverage-reports'
// 设置Python模块路径
PYTHONPATH = "${WORKSPACE}"
} }
stages { stages {
@@ -39,6 +41,10 @@ pipeline {
# 激活虚拟环境并安装依赖 # 激活虚拟环境并安装依赖
. ${VENV_NAME}/bin/activate . ${VENV_NAME}/bin/activate
pip config set global.index-url https://mirrors.ustc.edu.cn/pypi/simple
# 设置PYTHONPATH
export PYTHONPATH=\${PWD}:\$PYTHONPATH
# 升级pip # 升级pip
pip install --upgrade pip pip install --upgrade pip
@@ -64,12 +70,13 @@ pipeline {
echo 'Running code linting...' echo 'Running code linting...'
sh """ sh """
. ${VENV_NAME}/bin/activate . ${VENV_NAME}/bin/activate
export PYTHONPATH=\${PWD}:\$PYTHONPATH
# 运行flake8检查 # 运行flake8检查(使用项目配置.flake8)
flake8 entity/ services/ --output-file=${TEST_REPORTS_DIR}/flake8-report.txt --tee || true flake8 entity/ services/ --output-file=${TEST_REPORTS_DIR}/flake8-report.txt --tee || true
# 运行black格式检查 # 运行black格式检查
black --check --diff entity/ services/ > ${TEST_REPORTS_DIR}/black-report.txt || true black --check --diff --line-length 88 entity/ services/ > ${TEST_REPORTS_DIR}/black-report.txt || true
""" """
} }
} }
@@ -94,6 +101,7 @@ pipeline {
echo 'Running type checking...' echo 'Running type checking...'
sh """ sh """
. ${VENV_NAME}/bin/activate . ${VENV_NAME}/bin/activate
export PYTHONPATH=\${PWD}:\$PYTHONPATH
# 运行mypy类型检查 # 运行mypy类型检查
mypy entity/ services/ --html-report ${TEST_REPORTS_DIR}/mypy-html --txt-report ${TEST_REPORTS_DIR}/mypy-txt || true mypy entity/ services/ --html-report ${TEST_REPORTS_DIR}/mypy-html --txt-report ${TEST_REPORTS_DIR}/mypy-txt || true
@@ -110,6 +118,7 @@ pipeline {
echo 'Running unit tests...' echo 'Running unit tests...'
sh """ sh """
. ${VENV_NAME}/bin/activate . ${VENV_NAME}/bin/activate
export PYTHONPATH=\${PWD}:\$PYTHONPATH
# 运行单元测试 # 运行单元测试
pytest tests/test_effects/ tests/test_ffmpeg_builder/ \\ pytest tests/test_effects/ tests/test_ffmpeg_builder/ \\
@@ -158,6 +167,7 @@ pipeline {
echo 'Running integration tests...' echo 'Running integration tests...'
sh """ sh """
. ${VENV_NAME}/bin/activate . ${VENV_NAME}/bin/activate
export PYTHONPATH=\${PWD}:\$PYTHONPATH
# 运行集成测试 # 运行集成测试
pytest tests/test_integration/ \\ pytest tests/test_integration/ \\
@@ -198,6 +208,7 @@ pipeline {
echo 'Running complete test suite with coverage...' echo 'Running complete test suite with coverage...'
sh """ sh """
. ${VENV_NAME}/bin/activate . ${VENV_NAME}/bin/activate
export PYTHONPATH=\${PWD}:\$PYTHONPATH
# 运行完整测试套件 # 运行完整测试套件
pytest tests/ \\ pytest tests/ \\
@@ -262,6 +273,7 @@ pipeline {
echo 'Running performance tests...' echo 'Running performance tests...'
sh """ sh """
. ${VENV_NAME}/bin/activate . ${VENV_NAME}/bin/activate
export PYTHONPATH=\${PWD}:\$PYTHONPATH
# 运行性能测试 # 运行性能测试
RUN_STRESS_TESTS=1 pytest tests/test_integration/test_ffmpeg_execution.py::TestFFmpegExecution::test_stress_test_large_effects_chain \\ RUN_STRESS_TESTS=1 pytest tests/test_integration/test_ffmpeg_execution.py::TestFFmpegExecution::test_stress_test_large_effects_chain \\

View File

@@ -1,4 +1,4 @@
from typing import List, Dict, Any from typing import List
from .base import EffectProcessor from .base import EffectProcessor
@@ -63,20 +63,24 @@ class CameraShotEffect(EffectProcessor):
# 选择开始部分帧 # 选择开始部分帧
filter_args.append( filter_args.append(
f"{start_out_str}select=lt(n\\,{int(start * self.frame_rate)}){start_out_str}" f"{start_out_str}select=lt(n\\,"
f"{int(start * self.frame_rate)}){start_out_str}"
) )
# 选择结束部分帧 # 选择结束部分帧
filter_args.append( filter_args.append(
f"{end_out_str}select=gt(n\\,{int(start * self.frame_rate)}){end_out_str}" f"{end_out_str}select=gt(n\\,"
f"{int(start * self.frame_rate)}){end_out_str}"
) )
# 选择中间特定帧并扩展 # 选择中间特定帧并扩展
filter_args.append( filter_args.append(
f"{mid_out_str}select=eq(n\\,{int(start * self.frame_rate)}){mid_out_str}" f"{mid_out_str}select=eq(n\\,"
f"{int(start * self.frame_rate)}){mid_out_str}"
) )
filter_args.append( filter_args.append(
f"{mid_out_str}tpad=start_mode=clone:start_duration={duration:.4f}{mid_out_str}" f"{mid_out_str}tpad=start_mode=clone:"
f"start_duration={duration:.4f}{mid_out_str}"
) )
# 如果需要旋转 # 如果需要旋转
@@ -85,7 +89,8 @@ class CameraShotEffect(EffectProcessor):
# 连接三部分 # 连接三部分
filter_args.append( filter_args.append(
f"{start_out_str}{mid_out_str}{end_out_str}concat=n=3:v=1:a=0,setpts=N/{self.frame_rate}/TB{final_output}" f"{start_out_str}{mid_out_str}{end_out_str}concat=n=3:v=1:a=0,"
f"setpts=N/{self.frame_rate}/TB{final_output}"
) )
return filter_args, final_output return filter_args, final_output

View File

@@ -35,7 +35,8 @@ class TailEffect(EffectProcessor):
# 使用reverse+trim+reverse的方法来精确获取最后N秒 # 使用reverse+trim+reverse的方法来精确获取最后N秒
filter_args = [ filter_args = [
f"{video_input}reverse[v_rev{effect_index}]", f"{video_input}reverse[v_rev{effect_index}]",
f"[v_rev{effect_index}]trim=duration={tail_seconds}[v_trim{effect_index}]", f"[v_rev{effect_index}]trim=duration={tail_seconds}"
f"[v_trim{effect_index}]",
f"[v_trim{effect_index}]reverse{output_stream}", f"[v_trim{effect_index}]reverse{output_stream}",
] ]

View File

@@ -1,5 +1,4 @@
from typing import List from typing import List
import json
from .base import EffectProcessor from .base import EffectProcessor

View File

@@ -1,4 +1,3 @@
import os
import time import time
from typing import List, Optional from typing import List, Optional
@@ -10,8 +9,6 @@ from util.ffmpeg import probe_video_info, probe_video_audio
from util.ffmpeg_utils import ( from util.ffmpeg_utils import (
build_base_ffmpeg_args, build_base_ffmpeg_args,
build_null_audio_input, build_null_audio_input,
build_amix_filter,
build_overlay_scale_filter,
get_annexb_filter, get_annexb_filter,
build_standard_output_args, build_standard_output_args,
) )
@@ -209,8 +206,6 @@ class FFmpegCommandBuilder:
pos_json = self.task.ext_data.get("posJson", "{}") pos_json = self.task.ext_data.get("posJson", "{}")
pos_data = safe_json_loads(pos_json, {}) pos_data = safe_json_loads(pos_json, {})
_v_w = pos_data.get("imgWidth", 1)
_v_h = pos_data.get("imgHeight", 1)
_f_x = pos_data.get("ltX", 0) _f_x = pos_data.get("ltX", 0)
_f_x2 = pos_data.get("rbX", 0) _f_x2 = pos_data.get("rbX", 0)
_f_y = pos_data.get("ltY", 0) _f_y = pos_data.get("ltY", 0)

View File

@@ -1,10 +1,8 @@
import os
import uuid import uuid
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from config.settings import get_ffmpeg_config
from util.exceptions import TaskValidationError, EffectError from util.exceptions import TaskValidationError, EffectError
from entity.effects import registry as effect_registry from entity.effects import registry as effect_registry

View File

@@ -16,11 +16,7 @@ def run_command(cmd, cwd=None):
print(f"Running: {' '.join(cmd)}") print(f"Running: {' '.join(cmd)}")
try: try:
result = subprocess.run( result = subprocess.run(
cmd, cmd, cwd=cwd, capture_output=True, text=True, check=False
cwd=cwd,
capture_output=True,
text=True,
check=False
) )
if result.stdout: if result.stdout:
print(result.stdout) print(result.stdout)
@@ -39,7 +35,9 @@ def check_dependencies():
# 检查pytest # 检查pytest
if not run_command([sys.executable, "-c", "import pytest"]): if not run_command([sys.executable, "-c", "import pytest"]):
print("pytest not found. Installing test dependencies...") print("pytest not found. Installing test dependencies...")
if not run_command([sys.executable, "-m", "pip", "install", "-r", "requirements-test.txt"]): if not run_command(
[sys.executable, "-m", "pip", "install", "-r", "requirements-test.txt"]
):
print("Failed to install test dependencies", file=sys.stderr) print("Failed to install test dependencies", file=sys.stderr)
return False return False
@@ -56,22 +54,27 @@ def run_unit_tests(args):
print("\n=== Running Unit Tests ===") print("\n=== Running Unit Tests ===")
cmd = [ cmd = [
sys.executable, "-m", "pytest", sys.executable,
"-m",
"pytest",
"tests/test_effects/", "tests/test_effects/",
"tests/test_ffmpeg_builder/", "tests/test_ffmpeg_builder/",
"-v", "-v",
"-m", "not integration" "-m",
"not integration",
] ]
if args.coverage: if args.coverage:
cmd.extend([ cmd.extend(
"--cov=entity", [
"--cov=services", "--cov=entity",
"--cov-report=xml:coverage.xml", "--cov=services",
"--cov-report=html:htmlcov", "--cov-report=xml:coverage.xml",
"--cov-report=term-missing", "--cov-report=html:htmlcov",
"--cov-branch" "--cov-report=term-missing",
]) "--cov-branch",
]
)
if args.xml_report: if args.xml_report:
cmd.extend(["--junitxml=unit-tests.xml"]) cmd.extend(["--junitxml=unit-tests.xml"])
@@ -92,21 +95,26 @@ def run_integration_tests(args):
return True return True
cmd = [ cmd = [
sys.executable, "-m", "pytest", sys.executable,
"-m",
"pytest",
"tests/test_integration/", "tests/test_integration/",
"-v", "-v",
"-m", "integration", "-m",
"--timeout=300" "integration",
"--timeout=300",
] ]
if args.coverage: if args.coverage:
cmd.extend([ cmd.extend(
"--cov=entity", [
"--cov=services", "--cov=entity",
"--cov-report=xml:integration-coverage.xml", "--cov=services",
"--cov-report=html:integration-htmlcov", "--cov-report=xml:integration-coverage.xml",
"--cov-branch" "--cov-report=html:integration-htmlcov",
]) "--cov-branch",
]
)
if args.xml_report: if args.xml_report:
cmd.extend(["--junitxml=integration-tests.xml"]) cmd.extend(["--junitxml=integration-tests.xml"])
@@ -121,21 +129,19 @@ def run_all_tests(args):
"""运行所有测试""" """运行所有测试"""
print("\n=== Running All Tests ===") print("\n=== Running All Tests ===")
cmd = [ cmd = [sys.executable, "-m", "pytest", "tests/", "-v"]
sys.executable, "-m", "pytest",
"tests/",
"-v"
]
if args.coverage: if args.coverage:
cmd.extend([ cmd.extend(
"--cov=entity", [
"--cov=services", "--cov=entity",
"--cov-report=xml:coverage.xml", "--cov=services",
"--cov-report=html:htmlcov", "--cov-report=xml:coverage.xml",
"--cov-report=term-missing", "--cov-report=html:htmlcov",
"--cov-branch" "--cov-report=term-missing",
]) "--cov-branch",
]
)
if args.fail_under: if args.fail_under:
cmd.extend([f"--cov-fail-under={args.fail_under}"]) cmd.extend([f"--cov-fail-under={args.fail_under}"])
@@ -154,17 +160,15 @@ def run_effect_tests(effect_name=None):
if effect_name: if effect_name:
print(f"\n=== Running {effect_name} Effect Tests ===") print(f"\n=== Running {effect_name} Effect Tests ===")
cmd = [ cmd = [
sys.executable, "-m", "pytest", sys.executable,
"-m",
"pytest",
f"tests/test_effects/test_{effect_name}_effect.py", f"tests/test_effects/test_{effect_name}_effect.py",
"-v" "-v",
] ]
else: else:
print("\n=== Running All Effect Tests ===") print("\n=== Running All Effect Tests ===")
cmd = [ cmd = [sys.executable, "-m", "pytest", "tests/test_effects/", "-v"]
sys.executable, "-m", "pytest",
"tests/test_effects/",
"-v"
]
return run_command(cmd) return run_command(cmd)
@@ -174,14 +178,17 @@ def run_stress_tests():
print("\n=== Running Stress Tests ===") print("\n=== Running Stress Tests ===")
env = os.environ.copy() env = os.environ.copy()
env['RUN_STRESS_TESTS'] = '1' env["RUN_STRESS_TESTS"] = "1"
cmd = [ cmd = [
sys.executable, "-m", "pytest", sys.executable,
"-m",
"pytest",
"tests/test_integration/", "tests/test_integration/",
"-v", "-v",
"-m", "stress", "-m",
"--timeout=600" "stress",
"--timeout=600",
] ]
return subprocess.run(cmd, env=env).returncode == 0 return subprocess.run(cmd, env=env).returncode == 0
@@ -201,13 +208,19 @@ def create_test_video():
# 创建短视频文件 # 创建短视频文件
video_path = test_data_dir / "sample.mp4" video_path = test_data_dir / "sample.mp4"
cmd = [ cmd = [
"ffmpeg", "-y", "ffmpeg",
"-f", "lavfi", "-y",
"-i", "testsrc=duration=5:size=640x480:rate=25", "-f",
"-c:v", "libx264", "lavfi",
"-preset", "ultrafast", "-i",
"-crf", "23", "testsrc=duration=5:size=640x480:rate=25",
str(video_path) "-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"23",
str(video_path),
] ]
if run_command(cmd): if run_command(cmd):
@@ -220,16 +233,30 @@ def create_test_video():
def main(): def main():
parser = argparse.ArgumentParser(description="RenderWorker Test Runner") parser = argparse.ArgumentParser(description="RenderWorker Test Runner")
parser.add_argument("test_type", choices=[ parser.add_argument(
"unit", "integration", "all", "effects", "stress", "setup" "test_type",
], help="Type of tests to run") choices=["unit", "integration", "all", "effects", "stress", "setup"],
help="Type of tests to run",
)
parser.add_argument("--effect", help="Specific effect to test (for effects command)") parser.add_argument(
parser.add_argument("--coverage", action="store_true", help="Generate coverage report") "--effect", help="Specific effect to test (for effects command)"
parser.add_argument("--xml-report", action="store_true", help="Generate XML test report") )
parser.add_argument("--html-report", action="store_true", help="Generate HTML test report") parser.add_argument(
parser.add_argument("--fail-under", type=int, default=70, help="Minimum coverage percentage") "--coverage", action="store_true", help="Generate coverage report"
parser.add_argument("--no-deps-check", action="store_true", help="Skip dependency check") )
parser.add_argument(
"--xml-report", action="store_true", help="Generate XML test report"
)
parser.add_argument(
"--html-report", action="store_true", help="Generate HTML test report"
)
parser.add_argument(
"--fail-under", type=int, default=70, help="Minimum coverage percentage"
)
parser.add_argument(
"--no-deps-check", action="store_true", help="Skip dependency check"
)
args = parser.parse_args() args = parser.parse_args()
@@ -264,4 +291,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -2,12 +2,13 @@ import subprocess
import os import os
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional, Union from typing import Union
from opentelemetry.trace import Status, StatusCode from opentelemetry.trace import Status, StatusCode
from entity.render_task import RenderTask from entity.render_task import RenderTask
from entity.ffmpeg_command_builder import FFmpegCommandBuilder from entity.ffmpeg_command_builder import FFmpegCommandBuilder
from entity.ffmpeg import FfmpegTask
from util.exceptions import RenderError, FFmpegError from util.exceptions import RenderError, FFmpegError
from util.ffmpeg import ( from util.ffmpeg import (
probe_video_info, probe_video_info,
@@ -26,7 +27,7 @@ class RenderService(ABC):
"""渲染服务抽象接口""" """渲染服务抽象接口"""
@abstractmethod @abstractmethod
def render(self, task: Union[RenderTask, "FfmpegTask"]) -> bool: def render(self, task: Union[RenderTask, FfmpegTask]) -> bool:
""" """
执行渲染任务 执行渲染任务
@@ -72,7 +73,7 @@ class RenderService(ABC):
class DefaultRenderService(RenderService): class DefaultRenderService(RenderService):
"""默认渲染服务实现""" """默认渲染服务实现"""
def render(self, task: Union[RenderTask, "FfmpegTask"]) -> bool: def render(self, task: Union[RenderTask, FfmpegTask]) -> bool:
"""执行渲染任务""" """执行渲染任务"""
# 兼容旧的FfmpegTask # 兼容旧的FfmpegTask
if hasattr(task, "get_ffmpeg_args"): # 这是FfmpegTask if hasattr(task, "get_ffmpeg_args"): # 这是FfmpegTask
@@ -146,7 +147,7 @@ class DefaultRenderService(RenderService):
error_msg, error_msg,
) )
raise FFmpegError( raise FFmpegError(
f"FFmpeg execution failed", "FFmpeg execution failed",
command=args, command=args,
return_code=process.returncode, return_code=process.returncode,
stderr=error_msg, stderr=error_msg,

View File

@@ -3,9 +3,14 @@
""" """
import threading import threading
from typing import Dict, Type, TypeVar, Optional from typing import Dict, Type, TypeVar, Optional, TYPE_CHECKING
import logging import logging
if TYPE_CHECKING:
from .render_service import RenderService
from .template_service import TemplateService
from .task_service import TaskService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
T = TypeVar("T") T = TypeVar("T")

View File

@@ -2,7 +2,7 @@ import logging
import os import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Any, List, Optional from typing import Dict, Any, Optional
from opentelemetry.trace import Status, StatusCode from opentelemetry.trace import Status, StatusCode
@@ -125,7 +125,11 @@ class DefaultTaskService(TaskService):
# 报告任务成功 # 报告任务成功
api.report_task_success( api.report_task_success(
task_info, task_info,
videoInfo={"width": width, "height": height, "duration": duration}, videoInfo={
"width": width,
"height": height,
"duration": duration,
},
) )
span.set_status(Status(StatusCode.OK)) span.set_status(Status(StatusCode.OK))
@@ -249,7 +253,10 @@ class DefaultTaskService(TaskService):
) )
def _parse_video_source( def _parse_video_source(
self, source: str, task_params: Dict[str, Any], template_info: Dict[str, Any] self,
source: str,
task_params: Dict[str, Any],
template_info: Dict[str, Any],
) -> tuple[Optional[str], Dict[str, Any]]: ) -> tuple[Optional[str], Dict[str, Any]]:
"""解析视频源""" """解析视频源"""
if source.startswith("PLACEHOLDER_"): if source.startswith("PLACEHOLDER_"):
@@ -274,7 +281,10 @@ class DefaultTaskService(TaskService):
return os.path.join(template_info.get("local_path", ""), source), {} return os.path.join(template_info.get("local_path", ""), source), {}
def _check_placeholder_exist_with_count( def _check_placeholder_exist_with_count(
self, placeholder_id: str, task_params: Dict[str, Any], required_count: int = 1 self,
placeholder_id: str,
task_params: Dict[str, Any],
required_count: int = 1,
) -> bool: ) -> bool:
"""检查占位符是否存在足够数量的片段""" """检查占位符是否存在足够数量的片段"""
if placeholder_id in task_params: if placeholder_id in task_params:

View File

@@ -1,4 +1,5 @@
"""pytest配置文件""" """pytest配置文件"""
import pytest import pytest
import os import os
import tempfile import tempfile
@@ -7,7 +8,7 @@ from pathlib import Path
from typing import Dict, Any from typing import Dict, Any
from entity.render_task import RenderTask from entity.render_task import RenderTask
from config.settings import Config from config.settings import FFmpegConfig, APIConfig, StorageConfig
@pytest.fixture @pytest.fixture
@@ -19,14 +20,28 @@ def temp_dir():
@pytest.fixture @pytest.fixture
def test_config(): def test_ffmpeg_config():
"""测试用配置""" """测试用FFmpeg配置"""
return Config( return FFmpegConfig(
encoder_args="-c:v h264", encoder_args="-c:v h264",
video_args="", video_args="",
)
@pytest.fixture
def test_api_config():
"""测试用API配置"""
return APIConfig(
endpoint="http://test.local",
access_key="test_key",
)
@pytest.fixture
def test_storage_config():
"""测试用存储配置"""
return StorageConfig(
template_dir="tests/test_data/templates", template_dir="tests/test_data/templates",
api_endpoint="http://test.local",
access_key="test_key"
) )
@@ -41,7 +56,7 @@ def sample_render_task():
effects=["zoom:0,2.0,3.0", "ospeed:1.5"], effects=["zoom:0,2.0,3.0", "ospeed:1.5"],
ext_data={ ext_data={
"posJson": '{"ltX": 100, "ltY": 100, "rbX": 200, "rbY": 200, "imgWidth": 300, "imgHeight": 300}' "posJson": '{"ltX": 100, "ltY": 100, "rbX": 200, "rbY": 200, "imgWidth": 300, "imgHeight": 300}'
} },
) )
@@ -57,6 +72,7 @@ def sample_video_file(temp_dir):
def ffmpeg_available(): def ffmpeg_available():
"""检查FFmpeg是否可用""" """检查FFmpeg是否可用"""
import subprocess import subprocess
try: try:
subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True) subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
return True return True
@@ -69,7 +85,7 @@ def mock_ext_data():
"""模拟扩展数据""" """模拟扩展数据"""
return { return {
"posJson": '{"ltX": 50, "ltY": 50, "rbX": 150, "rbY": 150, "imgWidth": 200, "imgHeight": 200}', "posJson": '{"ltX": 50, "ltY": 50, "rbX": 150, "rbY": 150, "imgWidth": 200, "imgHeight": 200}',
"templateData": {"width": 1920, "height": 1080} "templateData": {"width": 1920, "height": 1080},
} }
@@ -78,6 +94,7 @@ def pytest_collection_modifyitems(config, items):
"""根据FFmpeg可用性修改测试收集""" """根据FFmpeg可用性修改测试收集"""
try: try:
import subprocess import subprocess
subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True) subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
ffmpeg_available = True ffmpeg_available = True
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
@@ -87,4 +104,4 @@ def pytest_collection_modifyitems(config, items):
skip_ffmpeg = pytest.mark.skip(reason="FFmpeg not available") skip_ffmpeg = pytest.mark.skip(reason="FFmpeg not available")
for item in items: for item in items:
if "integration" in item.keywords: if "integration" in item.keywords:
item.add_marker(skip_ffmpeg) item.add_marker(skip_ffmpeg)

View File

@@ -1,4 +1,5 @@
"""测试特效基础类和注册表""" """测试特效基础类和注册表"""
import pytest import pytest
from unittest.mock import Mock from unittest.mock import Mock
@@ -50,9 +51,7 @@ class TestEffectProcessor:
def test_get_pos_json_valid(self): def test_get_pos_json_valid(self):
"""测试获取有效位置JSON""" """测试获取有效位置JSON"""
ext_data = { ext_data = {"posJson": '{"ltX": 100, "ltY": 200, "rbX": 300, "rbY": 400}'}
"posJson": '{"ltX": 100, "ltY": 200, "rbX": 300, "rbY": 400}'
}
processor = EffectProcessor("", ext_data) processor = EffectProcessor("", ext_data)
result = processor.get_pos_json() result = processor.get_pos_json()
@@ -185,4 +184,4 @@ class TestEffectRegistry:
name, params = registry.parse_effect_string("effect:param1:param2") name, params = registry.parse_effect_string("effect:param1:param2")
assert name == "effect" assert name == "effect"
assert params == "param1:param2" assert params == "param1:param2"

View File

@@ -1,4 +1,5 @@
"""测试变速特效""" """测试变速特效"""
import pytest import pytest
from entity.effects.speed import SpeedEffect from entity.effects.speed import SpeedEffect
from tests.utils.test_helpers import EffectTestHelper from tests.utils.test_helpers import EffectTestHelper
@@ -10,31 +11,11 @@ class TestSpeedEffect:
def test_validate_params_valid_cases(self): def test_validate_params_valid_cases(self):
"""测试有效参数验证""" """测试有效参数验证"""
test_cases = [ test_cases = [
{ {"params": "2.0", "expected": True, "description": "2倍速"},
"params": "2.0", {"params": "0.5", "expected": True, "description": "0.5倍速(慢速)"},
"expected": True, {"params": "1.0", "expected": True, "description": "正常速度"},
"description": "2倍速" {"params": "10.0", "expected": True, "description": "10倍速"},
}, {"params": "", "expected": True, "description": "空参数(默认不变速)"},
{
"params": "0.5",
"expected": True,
"description": "0.5倍速(慢速)"
},
{
"params": "1.0",
"expected": True,
"description": "正常速度"
},
{
"params": "10.0",
"expected": True,
"description": "10倍速"
},
{
"params": "",
"expected": True,
"description": "空参数(默认不变速)"
}
] ]
effect = SpeedEffect() effect = SpeedEffect()
@@ -46,26 +27,10 @@ class TestSpeedEffect:
def test_validate_params_invalid_cases(self): def test_validate_params_invalid_cases(self):
"""测试无效参数验证""" """测试无效参数验证"""
test_cases = [ test_cases = [
{ {"params": "0", "expected": False, "description": "零速度"},
"params": "0", {"params": "-1.0", "expected": False, "description": "负速度"},
"expected": False, {"params": "abc", "expected": False, "description": "非数字参数"},
"description": "零速度" {"params": "1.0,2.0", "expected": False, "description": "多余参数"},
},
{
"params": "-1.0",
"expected": False,
"description": "负速度"
},
{
"params": "abc",
"expected": False,
"description": "非数字参数"
},
{
"params": "1.0,2.0",
"expected": False,
"description": "多余参数"
}
] ]
effect = SpeedEffect() effect = SpeedEffect()
@@ -110,7 +75,7 @@ class TestSpeedEffect:
test_cases = [ test_cases = [
{"params": "", "description": "空参数"}, {"params": "", "description": "空参数"},
{"params": "1", "description": "1倍速"}, {"params": "1", "description": "1倍速"},
{"params": "1.0", "description": "1.0倍速"} {"params": "1.0", "description": "1.0倍速"},
] ]
for case in test_cases: for case in test_cases:
@@ -118,8 +83,12 @@ class TestSpeedEffect:
result = EffectTestHelper.test_filter_generation(effect, "[0:v]", 1) result = EffectTestHelper.test_filter_generation(effect, "[0:v]", 1)
assert result["success"], f"Failed for {case['description']}" assert result["success"], f"Failed for {case['description']}"
assert result["filter_count"] == 0, f"Should not generate filter for {case['description']}" assert (
assert result["output_stream"] == "[0:v]", f"Output should equal input for {case['description']}" result["filter_count"] == 0
), f"Should not generate filter for {case['description']}"
assert (
result["output_stream"] == "[0:v]"
), f"Output should equal input for {case['description']}"
def test_generate_filter_args_invalid_params(self): def test_generate_filter_args_invalid_params(self):
"""测试无效参数的滤镜生成""" """测试无效参数的滤镜生成"""
@@ -166,4 +135,4 @@ class TestSpeedEffect:
# 作为链中的第二个特效 # 作为链中的第二个特效
result2 = EffectTestHelper.test_filter_generation(effect, "[v_eff1]", 2) result2 = EffectTestHelper.test_filter_generation(effect, "[v_eff1]", 2)
assert result2["output_stream"] == "[v_eff2]" assert result2["output_stream"] == "[v_eff2]"
assert "[v_eff1]" in result2["filters"][0] assert "[v_eff1]" in result2["filters"][0]

View File

@@ -1,4 +1,5 @@
"""测试缩放特效""" """测试缩放特效"""
import pytest import pytest
from entity.effects.zoom import ZoomEffect from entity.effects.zoom import ZoomEffect
from tests.utils.test_helpers import EffectTestHelper, FFmpegValidator from tests.utils.test_helpers import EffectTestHelper, FFmpegValidator
@@ -10,26 +11,18 @@ class TestZoomEffect:
def test_validate_params_valid_cases(self): def test_validate_params_valid_cases(self):
"""测试有效参数验证""" """测试有效参数验证"""
test_cases = [ test_cases = [
{ {"params": "0,2.0,3.0", "expected": True, "description": "标准缩放参数"},
"params": "0,2.0,3.0",
"expected": True,
"description": "标准缩放参数"
},
{ {
"params": "1.5,1.5,0", "params": "1.5,1.5,0",
"expected": True, "expected": True,
"description": "静态缩放(duration=0)" "description": "静态缩放(duration=0)",
}, },
{ {
"params": "0,1.0,5.0", "params": "0,1.0,5.0",
"expected": True, "expected": True,
"description": "无缩放效果(factor=1.0)" "description": "无缩放效果(factor=1.0)",
}, },
{ {"params": "10,0.5,2.0", "expected": True, "description": "缩小效果"},
"params": "10,0.5,2.0",
"expected": True,
"description": "缩小效果"
}
] ]
effect = ZoomEffect() effect = ZoomEffect()
@@ -41,41 +34,13 @@ class TestZoomEffect:
def test_validate_params_invalid_cases(self): def test_validate_params_invalid_cases(self):
"""测试无效参数验证""" """测试无效参数验证"""
test_cases = [ test_cases = [
{ {"params": "", "expected": False, "description": "空参数"},
"params": "", {"params": "1,2", "expected": False, "description": "参数不足"},
"expected": False, {"params": "-1,2.0,3.0", "expected": False, "description": "负开始时间"},
"description": "空参数" {"params": "0,0,3.0", "expected": False, "description": "零缩放因子"},
}, {"params": "0,-2.0,3.0", "expected": False, "description": "负缩放因子"},
{ {"params": "0,2.0,-1.0", "expected": False, "description": "负持续时间"},
"params": "1,2", {"params": "abc,2.0,3.0", "expected": False, "description": "非数字参数"},
"expected": False,
"description": "参数不足"
},
{
"params": "-1,2.0,3.0",
"expected": False,
"description": "负开始时间"
},
{
"params": "0,0,3.0",
"expected": False,
"description": "零缩放因子"
},
{
"params": "0,-2.0,3.0",
"expected": False,
"description": "负缩放因子"
},
{
"params": "0,2.0,-1.0",
"expected": False,
"description": "负持续时间"
},
{
"params": "abc,2.0,3.0",
"expected": False,
"description": "非数字参数"
}
] ]
effect = ZoomEffect() effect = ZoomEffect()
@@ -158,9 +123,7 @@ class TestZoomEffect:
def test_get_zoom_center_invalid_pos_json(self): def test_get_zoom_center_invalid_pos_json(self):
"""测试无效posJson时的缩放中心点""" """测试无效posJson时的缩放中心点"""
ext_data = { ext_data = {"posJson": '{"imgWidth": 0, "imgHeight": 0}'} # 无效尺寸
"posJson": '{"imgWidth": 0, "imgHeight": 0}' # 无效尺寸
}
effect = ZoomEffect("0,2.0,3.0", ext_data) effect = ZoomEffect("0,2.0,3.0", ext_data)
center_x, center_y = effect._get_zoom_center() center_x, center_y = effect._get_zoom_center()
@@ -191,4 +154,4 @@ class TestZoomEffect:
assert "zoompan=z=" in filter_str assert "zoompan=z=" in filter_str
assert "between(t\\\\,2.5\\\\,4.0)" in filter_str # 2.5 + 1.5 = 4.0 assert "between(t\\\\,2.5\\\\,4.0)" in filter_str # 2.5 + 1.5 = 4.0
assert "[input]" in filter_str assert "[input]" in filter_str
assert "[v_eff5]" in filter_str assert "[v_eff5]" in filter_str

View File

@@ -1,27 +1,17 @@
"""测试FFmpeg命令构建器""" """测试FFmpeg命令构建器"""
import pytest import pytest
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from entity.ffmpeg_command_builder import FFmpegCommandBuilder from entity.ffmpeg_command_builder import FFmpegCommandBuilder
from entity.render_task import RenderTask from entity.render_task import RenderTask
from config.settings import Config from config.settings import FFmpegConfig, APIConfig, StorageConfig
from tests.utils.test_helpers import MockRenderTask, FFmpegValidator from tests.utils.test_helpers import MockRenderTask, FFmpegValidator
class TestFFmpegCommandBuilder: class TestFFmpegCommandBuilder:
"""测试FFmpeg命令构建器""" """测试FFmpeg命令构建器"""
@pytest.fixture
def mock_config(self):
"""模拟配置"""
return Config(
encoder_args="-c:v h264",
video_args="-preset fast",
template_dir="tests/test_data/templates",
api_endpoint="http://test.local",
access_key="test_key"
)
@pytest.fixture @pytest.fixture
def simple_task(self): def simple_task(self):
"""简单的渲染任务""" """简单的渲染任务"""
@@ -43,15 +33,15 @@ class TestFFmpegCommandBuilder:
} }
return task return task
def test_init(self, simple_task, mock_config): def test_init(self, simple_task):
"""测试初始化""" """测试初始化"""
builder = FFmpegCommandBuilder(simple_task, mock_config) builder = FFmpegCommandBuilder(simple_task)
assert builder.task == simple_task assert builder.task == simple_task
assert builder.config == mock_config assert builder.config is not None
def test_build_copy_command(self, simple_task, mock_config): def test_build_copy_command(self, simple_task):
"""测试构建复制命令""" """测试构建复制命令"""
builder = FFmpegCommandBuilder(simple_task, mock_config) builder = FFmpegCommandBuilder(simple_task)
command = builder._build_copy_command() command = builder._build_copy_command()
# 验证命令结构 # 验证命令结构
@@ -67,13 +57,13 @@ class TestFFmpegCommandBuilder:
assert "output.mp4" in command assert "output.mp4" in command
assert "-c" in command and "copy" in command assert "-c" in command and "copy" in command
def test_build_concat_command_multiple_files(self, mock_config): def test_build_concat_command_multiple_files(self):
"""测试构建多文件拼接命令""" """测试构建多文件拼接命令"""
task = MockRenderTask() task = MockRenderTask()
task.input_files = ["file1.mp4", "file2.mp4", "file3.mp4"] task.input_files = ["file1.mp4", "file2.mp4", "file3.mp4"]
task.output_path = "concat_output.mp4" task.output_path = "concat_output.mp4"
builder = FFmpegCommandBuilder(task, mock_config) builder = FFmpegCommandBuilder(task)
command = builder._build_concat_command() command = builder._build_concat_command()
validation = FFmpegValidator.validate_ffmpeg_command(command) validation = FFmpegValidator.validate_ffmpeg_command(command)
@@ -84,9 +74,9 @@ class TestFFmpegCommandBuilder:
assert "concat=n=3:v=1:a=1" in " ".join(command) assert "concat=n=3:v=1:a=1" in " ".join(command)
assert all(f"file{i}.mp4" in command for i in range(1, 4)) assert all(f"file{i}.mp4" in command for i in range(1, 4))
def test_build_encode_command_with_effects(self, task_with_effects, mock_config): def test_build_encode_command_with_effects(self, task_with_effects):
"""测试构建带特效的编码命令""" """测试构建带特效的编码命令"""
builder = FFmpegCommandBuilder(task_with_effects, mock_config) builder = FFmpegCommandBuilder(task_with_effects)
command = builder._build_encode_command() command = builder._build_encode_command()
validation = FFmpegValidator.validate_ffmpeg_command(command) validation = FFmpegValidator.validate_ffmpeg_command(command)
@@ -99,13 +89,13 @@ class TestFFmpegCommandBuilder:
# 应该包含编码参数 # 应该包含编码参数
assert "-c:v" in command and "h264" in command assert "-c:v" in command and "h264" in command
def test_add_effects_single_effect(self, mock_config): def test_add_effects_single_effect(self):
"""测试添加单个特效""" """测试添加单个特效"""
task = MockRenderTask() task = MockRenderTask()
task.effects = ["zoom:0,2.0,3.0"] task.effects = ["zoom:0,2.0,3.0"]
task.ext_data = {"posJson": "{}"} task.ext_data = {"posJson": "{}"}
builder = FFmpegCommandBuilder(task, mock_config) builder = FFmpegCommandBuilder(task)
filter_args = [] filter_args = []
result_input, result_index = builder._add_effects(filter_args, "[0:v]", 1) result_input, result_index = builder._add_effects(filter_args, "[0:v]", 1)
@@ -115,13 +105,13 @@ class TestFFmpegCommandBuilder:
assert result_input == "[v_eff1]" # 输出流应该更新 assert result_input == "[v_eff1]" # 输出流应该更新
assert result_index == 2 # 索引应该递增 assert result_index == 2 # 索引应该递增
def test_add_effects_multiple_effects(self, mock_config): def test_add_effects_multiple_effects(self):
"""测试添加多个特效""" """测试添加多个特效"""
task = MockRenderTask() task = MockRenderTask()
task.effects = ["zoom:0,2.0,3.0", "ospeed:1.5"] task.effects = ["zoom:0,2.0,3.0", "ospeed:1.5"]
task.ext_data = {"posJson": "{}"} task.ext_data = {"posJson": "{}"}
builder = FFmpegCommandBuilder(task, mock_config) builder = FFmpegCommandBuilder(task)
filter_args = [] filter_args = []
result_input, result_index = builder._add_effects(filter_args, "[0:v]", 1) result_input, result_index = builder._add_effects(filter_args, "[0:v]", 1)
@@ -131,12 +121,12 @@ class TestFFmpegCommandBuilder:
assert result_input == "[v_eff2]" # 最终输出流 assert result_input == "[v_eff2]" # 最终输出流
assert result_index == 3 # 索引应该递增两次 assert result_index == 3 # 索引应该递增两次
def test_add_effects_invalid_effect(self, mock_config): def test_add_effects_invalid_effect(self):
"""测试添加无效特效""" """测试添加无效特效"""
task = MockRenderTask() task = MockRenderTask()
task.effects = ["invalid_effect:params"] task.effects = ["invalid_effect:params"]
builder = FFmpegCommandBuilder(task, mock_config) builder = FFmpegCommandBuilder(task)
filter_args = [] filter_args = []
result_input, result_index = builder._add_effects(filter_args, "[0:v]", 1) result_input, result_index = builder._add_effects(filter_args, "[0:v]", 1)
@@ -145,12 +135,12 @@ class TestFFmpegCommandBuilder:
assert result_input == "[0:v]" # 输入流不变 assert result_input == "[0:v]" # 输入流不变
assert result_index == 1 # 索引不变 assert result_index == 1 # 索引不变
def test_add_effects_no_effects(self, mock_config): def test_add_effects_no_effects(self):
"""测试无特效情况""" """测试无特效情况"""
task = MockRenderTask() task = MockRenderTask()
task.effects = [] task.effects = []
builder = FFmpegCommandBuilder(task, mock_config) builder = FFmpegCommandBuilder(task)
filter_args = [] filter_args = []
result_input, result_index = builder._add_effects(filter_args, "[0:v]", 1) result_input, result_index = builder._add_effects(filter_args, "[0:v]", 1)
@@ -159,11 +149,11 @@ class TestFFmpegCommandBuilder:
assert result_index == 1 # 索引不变 assert result_index == 1 # 索引不变
assert len(filter_args) == 0 # 无滤镜添加 assert len(filter_args) == 0 # 无滤镜添加
def test_build_command_copy_mode(self, simple_task, mock_config): def test_build_command_copy_mode(self, simple_task):
"""测试构建复制模式命令""" """测试构建复制模式命令"""
simple_task.input_files = ["single_file.mp4"] simple_task.input_files = ["single_file.mp4"]
builder = FFmpegCommandBuilder(simple_task, mock_config) builder = FFmpegCommandBuilder(simple_task)
command = builder.build_command() command = builder.build_command()
validation = FFmpegValidator.validate_ffmpeg_command(command) validation = FFmpegValidator.validate_ffmpeg_command(command)
@@ -173,13 +163,13 @@ class TestFFmpegCommandBuilder:
command_str = " ".join(command) command_str = " ".join(command)
assert "-c copy" in command_str assert "-c copy" in command_str
def test_build_command_concat_mode(self, mock_config): def test_build_command_concat_mode(self):
"""测试构建拼接模式命令""" """测试构建拼接模式命令"""
task = MockRenderTask() task = MockRenderTask()
task.input_files = ["file1.mp4", "file2.mp4"] task.input_files = ["file1.mp4", "file2.mp4"]
task.effects = [] task.effects = []
builder = FFmpegCommandBuilder(task, mock_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
validation = FFmpegValidator.validate_ffmpeg_command(command) validation = FFmpegValidator.validate_ffmpeg_command(command)
@@ -190,9 +180,9 @@ class TestFFmpegCommandBuilder:
command_str = " ".join(command) command_str = " ".join(command)
assert "concat=" in command_str assert "concat=" in command_str
def test_build_command_encode_mode(self, task_with_effects, mock_config): def test_build_command_encode_mode(self, task_with_effects):
"""测试构建编码模式命令""" """测试构建编码模式命令"""
builder = FFmpegCommandBuilder(task_with_effects, mock_config) builder = FFmpegCommandBuilder(task_with_effects)
command = builder.build_command() command = builder.build_command()
validation = FFmpegValidator.validate_ffmpeg_command(command) validation = FFmpegValidator.validate_ffmpeg_command(command)
@@ -203,15 +193,15 @@ class TestFFmpegCommandBuilder:
command_str = " ".join(command) command_str = " ".join(command)
assert "-c:v h264" in command_str assert "-c:v h264" in command_str
@patch('entity.effects.registry.get_processor') @patch("entity.effects.registry.get_processor")
def test_effect_processor_integration(self, mock_get_processor, mock_config): def test_effect_processor_integration(self, mock_get_processor):
"""测试与特效处理器的集成""" """测试与特效处理器的集成"""
# 模拟特效处理器 # 模拟特效处理器
mock_processor = Mock() mock_processor = Mock()
mock_processor.frame_rate = 25 mock_processor.frame_rate = 25
mock_processor.generate_filter_args.return_value = ( mock_processor.generate_filter_args.return_value = (
["[0:v]zoompan=z=2.0:x=iw/2:y=ih/2:d=1[v_eff1]"], ["[0:v]zoompan=z=2.0:x=iw/2:y=ih/2:d=1[v_eff1]"],
"[v_eff1]" "[v_eff1]",
) )
mock_get_processor.return_value = mock_processor mock_get_processor.return_value = mock_processor
@@ -219,7 +209,7 @@ class TestFFmpegCommandBuilder:
task.effects = ["zoom:0,2.0,3.0"] task.effects = ["zoom:0,2.0,3.0"]
task.frame_rate = 30 task.frame_rate = 30
builder = FFmpegCommandBuilder(task, mock_config) builder = FFmpegCommandBuilder(task)
filter_args = [] filter_args = []
builder._add_effects(filter_args, "[0:v]", 1) builder._add_effects(filter_args, "[0:v]", 1)
@@ -228,12 +218,12 @@ class TestFFmpegCommandBuilder:
mock_processor.generate_filter_args.assert_called_once_with("[0:v]", 1) mock_processor.generate_filter_args.assert_called_once_with("[0:v]", 1)
assert mock_processor.frame_rate == 30 # 帧率应该被设置 assert mock_processor.frame_rate == 30 # 帧率应该被设置
def test_error_handling_missing_input(self, mock_config): def test_error_handling_missing_input(self):
"""测试缺少输入文件的错误处理""" """测试缺少输入文件的错误处理"""
task = MockRenderTask() task = MockRenderTask()
task.input_files = [] task.input_files = []
builder = FFmpegCommandBuilder(task, mock_config) builder = FFmpegCommandBuilder(task)
# 构建命令时应该处理错误情况 # 构建命令时应该处理错误情况
# 具体的错误处理依赖于实现 # 具体的错误处理依赖于实现
@@ -241,4 +231,4 @@ class TestFFmpegCommandBuilder:
# 验证至少返回了基本的ffmpeg命令结构 # 验证至少返回了基本的ffmpeg命令结构
assert isinstance(command, list) assert isinstance(command, list)
assert len(command) > 0 assert len(command) > 0

View File

@@ -1,4 +1,5 @@
"""FFmpeg执行集成测试""" """FFmpeg执行集成测试"""
import pytest import pytest
import subprocess import subprocess
import tempfile import tempfile
@@ -7,7 +8,7 @@ from pathlib import Path
from entity.ffmpeg_command_builder import FFmpegCommandBuilder from entity.ffmpeg_command_builder import FFmpegCommandBuilder
from entity.render_task import RenderTask from entity.render_task import RenderTask
from config.settings import Config from config.settings import FFmpegConfig
from services.render_service import DefaultRenderService from services.render_service import DefaultRenderService
from tests.utils.test_helpers import MockRenderTask, create_test_video_file from tests.utils.test_helpers import MockRenderTask, create_test_video_file
@@ -16,17 +17,6 @@ from tests.utils.test_helpers import MockRenderTask, create_test_video_file
class TestFFmpegExecution: class TestFFmpegExecution:
"""FFmpeg执行集成测试""" """FFmpeg执行集成测试"""
@pytest.fixture
def test_config(self, temp_dir):
"""测试配置"""
return Config(
encoder_args="-c:v libx264",
video_args="-preset ultrafast -crf 23",
template_dir=temp_dir,
api_endpoint="http://test.local",
access_key="test_key"
)
@pytest.fixture @pytest.fixture
def sample_video(self, temp_dir): def sample_video(self, temp_dir):
"""创建测试视频文件""" """创建测试视频文件"""
@@ -37,11 +27,11 @@ class TestFFmpegExecution:
return video_path return video_path
@pytest.fixture @pytest.fixture
def render_service(self, test_config): def render_service(self):
"""渲染服务实例""" """渲染服务实例"""
return DefaultRenderService() return DefaultRenderService()
def test_simple_copy_execution(self, sample_video, temp_dir, test_config): def test_simple_copy_execution(self, sample_video, temp_dir):
"""测试简单复制执行""" """测试简单复制执行"""
output_path = os.path.join(temp_dir, "copy_output.mp4") output_path = os.path.join(temp_dir, "copy_output.mp4")
@@ -50,7 +40,7 @@ class TestFFmpegExecution:
task.output_path = output_path task.output_path = output_path
task.effects = [] task.effects = []
builder = FFmpegCommandBuilder(task, test_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
# 执行命令 # 执行命令
@@ -62,7 +52,7 @@ class TestFFmpegExecution:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pytest.fail("FFmpeg execution timed out") pytest.fail("FFmpeg execution timed out")
def test_zoom_effect_execution(self, sample_video, temp_dir, test_config): def test_zoom_effect_execution(self, sample_video, temp_dir):
"""测试缩放特效执行""" """测试缩放特效执行"""
output_path = os.path.join(temp_dir, "zoom_output.mp4") output_path = os.path.join(temp_dir, "zoom_output.mp4")
@@ -72,7 +62,7 @@ class TestFFmpegExecution:
task.effects = ["zoom:0,2.0,2.0"] # 2倍缩放,持续2秒 task.effects = ["zoom:0,2.0,2.0"] # 2倍缩放,持续2秒
task.ext_data = {"posJson": "{}"} task.ext_data = {"posJson": "{}"}
builder = FFmpegCommandBuilder(task, test_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
try: try:
@@ -83,7 +73,7 @@ class TestFFmpegExecution:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pytest.fail("FFmpeg execution timed out") pytest.fail("FFmpeg execution timed out")
def test_speed_effect_execution(self, sample_video, temp_dir, test_config): def test_speed_effect_execution(self, sample_video, temp_dir):
"""测试变速特效执行""" """测试变速特效执行"""
output_path = os.path.join(temp_dir, "speed_output.mp4") output_path = os.path.join(temp_dir, "speed_output.mp4")
@@ -93,7 +83,7 @@ class TestFFmpegExecution:
task.effects = ["ospeed:2.0"] # 2倍速 task.effects = ["ospeed:2.0"] # 2倍速
task.ext_data = {} task.ext_data = {}
builder = FFmpegCommandBuilder(task, test_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
try: try:
@@ -104,7 +94,7 @@ class TestFFmpegExecution:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pytest.fail("FFmpeg execution timed out") pytest.fail("FFmpeg execution timed out")
def test_multiple_effects_execution(self, sample_video, temp_dir, test_config): def test_multiple_effects_execution(self, sample_video, temp_dir):
"""测试多特效组合执行""" """测试多特效组合执行"""
output_path = os.path.join(temp_dir, "multi_effects_output.mp4") output_path = os.path.join(temp_dir, "multi_effects_output.mp4")
@@ -114,7 +104,7 @@ class TestFFmpegExecution:
task.effects = ["zoom:0,1.5,1.0", "ospeed:1.5"] # 缩放+变速 task.effects = ["zoom:0,1.5,1.0", "ospeed:1.5"] # 缩放+变速
task.ext_data = {"posJson": "{}"} task.ext_data = {"posJson": "{}"}
builder = FFmpegCommandBuilder(task, test_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
try: try:
@@ -125,7 +115,7 @@ class TestFFmpegExecution:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pytest.fail("FFmpeg execution timed out") pytest.fail("FFmpeg execution timed out")
def test_concat_execution(self, temp_dir, test_config): def test_concat_execution(self, temp_dir):
"""测试视频拼接执行""" """测试视频拼接执行"""
# 创建两个测试视频 # 创建两个测试视频
video1_path = os.path.join(temp_dir, "video1.mp4") video1_path = os.path.join(temp_dir, "video1.mp4")
@@ -143,7 +133,7 @@ class TestFFmpegExecution:
task.output_path = output_path task.output_path = output_path
task.effects = [] task.effects = []
builder = FFmpegCommandBuilder(task, test_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
try: try:
@@ -154,7 +144,7 @@ class TestFFmpegExecution:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pytest.fail("FFmpeg execution timed out") pytest.fail("FFmpeg execution timed out")
def test_invalid_effect_execution(self, sample_video, temp_dir, test_config): def test_invalid_effect_execution(self, sample_video, temp_dir):
"""测试无效特效的执行处理""" """测试无效特效的执行处理"""
output_path = os.path.join(temp_dir, "invalid_effect_output.mp4") output_path = os.path.join(temp_dir, "invalid_effect_output.mp4")
@@ -164,7 +154,7 @@ class TestFFmpegExecution:
task.effects = ["invalid_effect:params", "zoom:0,2.0,1.0"] # 混合有效和无效特效 task.effects = ["invalid_effect:params", "zoom:0,2.0,1.0"] # 混合有效和无效特效
task.ext_data = {"posJson": "{}"} task.ext_data = {"posJson": "{}"}
builder = FFmpegCommandBuilder(task, test_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
# 应该忽略无效特效,继续处理有效特效 # 应该忽略无效特效,继续处理有效特效
@@ -175,7 +165,7 @@ class TestFFmpegExecution:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pytest.fail("FFmpeg execution timed out") pytest.fail("FFmpeg execution timed out")
def test_render_service_integration(self, sample_video, temp_dir, test_config, render_service): def test_render_service_integration(self, sample_video, temp_dir, render_service):
"""测试渲染服务集成""" """测试渲染服务集成"""
output_path = os.path.join(temp_dir, "service_output.mp4") output_path = os.path.join(temp_dir, "service_output.mp4")
@@ -187,7 +177,7 @@ class TestFFmpegExecution:
"output_path": output_path, "output_path": output_path,
"effects": ["zoom:0,1.8,2.0"], "effects": ["zoom:0,1.8,2.0"],
"ext_data": {"posJson": "{}"}, "ext_data": {"posJson": "{}"},
"frame_rate": 25 "frame_rate": 25,
} }
# 这里需要根据实际的RenderTask构造方法调整 # 这里需要根据实际的RenderTask构造方法调整
@@ -196,11 +186,11 @@ class TestFFmpegExecution:
# 使用渲染服务执行 # 使用渲染服务执行
try: try:
# 这里的方法调用需要根据实际的渲染服务接口调整 # 这里的方法调用需要根据实际的渲染服务接口调整
# success = render_service.render(task, test_config) # success = render_service.render(task)
# assert success, "Render service failed" # assert success, "Render service failed"
# 临时直接使用FFmpegCommandBuilder测试 # 临时直接使用FFmpegCommandBuilder测试
builder = FFmpegCommandBuilder(task, test_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
result = subprocess.run(command, capture_output=True, text=True, timeout=60) result = subprocess.run(command, capture_output=True, text=True, timeout=60)
@@ -210,7 +200,7 @@ class TestFFmpegExecution:
except Exception as e: except Exception as e:
pytest.fail(f"Render service integration failed: {e}") pytest.fail(f"Render service integration failed: {e}")
def test_error_handling_missing_input(self, temp_dir, test_config): def test_error_handling_missing_input(self, temp_dir):
"""测试缺失输入文件的错误处理""" """测试缺失输入文件的错误处理"""
missing_file = os.path.join(temp_dir, "nonexistent.mp4") missing_file = os.path.join(temp_dir, "nonexistent.mp4")
output_path = os.path.join(temp_dir, "error_output.mp4") output_path = os.path.join(temp_dir, "error_output.mp4")
@@ -220,14 +210,14 @@ class TestFFmpegExecution:
task.output_path = output_path task.output_path = output_path
task.effects = [] task.effects = []
builder = FFmpegCommandBuilder(task, test_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
# 应该失败,因为输入文件不存在 # 应该失败,因为输入文件不存在
result = subprocess.run(command, capture_output=True, text=True, timeout=30) result = subprocess.run(command, capture_output=True, text=True, timeout=30)
assert result.returncode != 0, "FFmpeg should fail with missing input file" assert result.returncode != 0, "FFmpeg should fail with missing input file"
def test_performance_multiple_effects(self, sample_video, temp_dir, test_config): def test_performance_multiple_effects(self, sample_video, temp_dir):
"""测试多特效性能""" """测试多特效性能"""
output_path = os.path.join(temp_dir, "performance_output.mp4") output_path = os.path.join(temp_dir, "performance_output.mp4")
@@ -235,21 +225,20 @@ class TestFFmpegExecution:
task.input_files = [sample_video] task.input_files = [sample_video]
task.output_path = output_path task.output_path = output_path
# 多个特效组合 # 多个特效组合
task.effects = [ task.effects = ["zoom:0,1.5,1.0", "ospeed:1.2", "zoom:1.5,2.0,1.0"]
"zoom:0,1.5,1.0",
"ospeed:1.2",
"zoom:1.5,2.0,1.0"
]
task.ext_data = {"posJson": "{}"} task.ext_data = {"posJson": "{}"}
builder = FFmpegCommandBuilder(task, test_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
import time import time
start_time = time.time() start_time = time.time()
try: try:
result = subprocess.run(command, capture_output=True, text=True, timeout=120) result = subprocess.run(
command, capture_output=True, text=True, timeout=120
)
execution_time = time.time() - start_time execution_time = time.time() - start_time
assert result.returncode == 0, f"FFmpeg failed: {result.stderr}" assert result.returncode == 0, f"FFmpeg failed: {result.stderr}"
@@ -259,8 +248,10 @@ class TestFFmpegExecution:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pytest.fail("FFmpeg execution timed out") pytest.fail("FFmpeg execution timed out")
@pytest.mark.skipif(not os.environ.get('RUN_STRESS_TESTS'), reason="Stress tests disabled") @pytest.mark.skipif(
def test_stress_test_large_effects_chain(self, sample_video, temp_dir, test_config): not os.environ.get("RUN_STRESS_TESTS"), reason="Stress tests disabled"
)
def test_stress_test_large_effects_chain(self, sample_video, temp_dir):
"""压力测试:大量特效链""" """压力测试:大量特效链"""
output_path = os.path.join(temp_dir, "stress_output.mp4") output_path = os.path.join(temp_dir, "stress_output.mp4")
@@ -271,12 +262,14 @@ class TestFFmpegExecution:
task.effects = [f"zoom:{i*0.5},1.{i+5},{i*0.2+0.5}" for i in range(10)] task.effects = [f"zoom:{i*0.5},1.{i+5},{i*0.2+0.5}" for i in range(10)]
task.ext_data = {"posJson": "{}"} task.ext_data = {"posJson": "{}"}
builder = FFmpegCommandBuilder(task, test_config) builder = FFmpegCommandBuilder(task)
command = builder.build_command() command = builder.build_command()
try: try:
result = subprocess.run(command, capture_output=True, text=True, timeout=300) result = subprocess.run(
command, capture_output=True, text=True, timeout=300
)
assert result.returncode == 0, f"Stress test failed: {result.stderr}" assert result.returncode == 0, f"Stress test failed: {result.stderr}"
assert os.path.exists(output_path), "Output file was not created" assert os.path.exists(output_path), "Output file was not created"
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pytest.fail("Stress test timed out") pytest.fail("Stress test timed out")

View File

@@ -1,4 +1,5 @@
"""测试辅助工具""" """测试辅助工具"""
import json import json
import tempfile import tempfile
import subprocess import subprocess
@@ -7,7 +8,7 @@ from pathlib import Path
from entity.render_task import RenderTask from entity.render_task import RenderTask
from entity.effects.base import EffectProcessor from entity.effects.base import EffectProcessor
from config.settings import Config from config.settings import FFmpegConfig
class MockRenderTask: class MockRenderTask:
@@ -20,7 +21,7 @@ class MockRenderTask:
effects: List[str] = None, effects: List[str] = None,
ext_data: Dict[str, Any] = None, ext_data: Dict[str, Any] = None,
frame_rate: int = 25, frame_rate: int = 25,
output_path: str = "test_output.mp4" output_path: str = "test_output.mp4",
): ):
self.task_id = task_id self.task_id = task_id
self.template_id = template_id self.template_id = template_id
@@ -47,11 +48,11 @@ class FFmpegValidator:
return False return False
# 检查是否包含基本的滤镜结构 # 检查是否包含基本的滤镜结构
if '[' in filter_str and ']' in filter_str: if "[" in filter_str and "]" in filter_str:
return True return True
# 检查常见的滤镜格式 # 检查常见的滤镜格式
common_filters = ['zoompan', 'setpts', 'trim', 'scale', 'crop'] common_filters = ["zoompan", "setpts", "trim", "scale", "crop"]
return any(f in filter_str for f in common_filters) return any(f in filter_str for f in common_filters)
except Exception: except Exception:
@@ -62,7 +63,7 @@ class FFmpegValidator:
"""验证流标识符格式""" """验证流标识符格式"""
if not stream_id: if not stream_id:
return False return False
return stream_id.startswith('[') and stream_id.endswith(']') return stream_id.startswith("[") and stream_id.endswith("]")
@staticmethod @staticmethod
def validate_ffmpeg_command(command: List[str]) -> Dict[str, Any]: def validate_ffmpeg_command(command: List[str]) -> Dict[str, Any]:
@@ -72,7 +73,7 @@ class FFmpegValidator:
"has_input": False, "has_input": False,
"has_output": False, "has_output": False,
"has_filter": False, "has_filter": False,
"errors": [] "errors": [],
} }
if not command or command[0] != "ffmpeg": if not command or command[0] != "ffmpeg":
@@ -96,9 +97,7 @@ class FFmpegValidator:
result["has_filter"] = True result["has_filter"] = True
result["valid"] = ( result["valid"] = (
result["has_input"] and result["has_input"] and result["has_output"] and len(result["errors"]) == 0
result["has_output"] and
len(result["errors"]) == 0
) )
return result return result
@@ -108,12 +107,16 @@ class EffectTestHelper:
"""特效测试辅助类""" """特效测试辅助类"""
@staticmethod @staticmethod
def create_test_effect(effect_class, params: str = "", ext_data: Dict[str, Any] = None): def create_test_effect(
effect_class, params: str = "", ext_data: Dict[str, Any] = None
):
"""创建测试用特效实例""" """创建测试用特效实例"""
return effect_class(params, ext_data) return effect_class(params, ext_data)
@staticmethod @staticmethod
def test_effect_params_validation(effect: EffectProcessor, test_cases: List[Dict[str, Any]]): def test_effect_params_validation(
effect: EffectProcessor, test_cases: List[Dict[str, Any]]
):
"""批量测试特效参数验证""" """批量测试特效参数验证"""
results = [] results = []
for case in test_cases: for case in test_cases:
@@ -123,29 +126,41 @@ class EffectTestHelper:
is_valid = effect.validate_params() is_valid = effect.validate_params()
expected = case.get("expected", True) expected = case.get("expected", True)
results.append({ results.append(
"params": effect.params, {
"expected": expected, "params": effect.params,
"actual": is_valid, "expected": expected,
"passed": is_valid == expected, "actual": is_valid,
"description": case.get("description", "") "passed": is_valid == expected,
}) "description": case.get("description", ""),
}
)
return results return results
@staticmethod @staticmethod
def test_filter_generation(effect: EffectProcessor, video_input: str = "[0:v]", effect_index: int = 1): def test_filter_generation(
effect: EffectProcessor, video_input: str = "[0:v]", effect_index: int = 1
):
"""测试滤镜生成""" """测试滤镜生成"""
try: try:
filters, output_stream = effect.generate_filter_args(video_input, effect_index) filters, output_stream = effect.generate_filter_args(
video_input, effect_index
)
result = { result = {
"success": True, "success": True,
"filters": filters, "filters": filters,
"output_stream": output_stream, "output_stream": output_stream,
"filter_count": len(filters), "filter_count": len(filters),
"valid_syntax": all(FFmpegValidator.validate_filter_syntax(f) for f in filters), "valid_syntax": all(
"valid_output": FFmpegValidator.validate_stream_identifier(output_stream) if output_stream != video_input else True FFmpegValidator.validate_filter_syntax(f) for f in filters
),
"valid_output": (
FFmpegValidator.validate_stream_identifier(output_stream)
if output_stream != video_input
else True
),
} }
except Exception as e: except Exception as e:
result = { result = {
@@ -155,23 +170,31 @@ class EffectTestHelper:
"output_stream": "", "output_stream": "",
"filter_count": 0, "filter_count": 0,
"valid_syntax": False, "valid_syntax": False,
"valid_output": False "valid_output": False,
} }
return result return result
def create_test_video_file(output_path: str, duration: int = 5, resolution: str = "640x480") -> bool: def create_test_video_file(
output_path: str, duration: int = 5, resolution: str = "640x480"
) -> bool:
"""创建测试用视频文件""" """创建测试用视频文件"""
try: try:
cmd = [ cmd = [
"ffmpeg", "-y", # 覆盖输出文件 "ffmpeg",
"-f", "lavfi", # 使用libavfilter输入 "-y", # 覆盖输出文件
"-i", f"testsrc=duration={duration}:size={resolution}:rate=25", "-f",
"-c:v", "libx264", "lavfi", # 使用libavfilter输入
"-preset", "ultrafast", "-i",
"-crf", "23", f"testsrc=duration={duration}:size={resolution}:rate=25",
output_path "-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"23",
output_path,
] ]
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
@@ -191,12 +214,8 @@ def create_sample_template_data() -> Dict[str, Any]:
"id": "part1", "id": "part1",
"type": "video", "type": "video",
"duration": 10.0, "duration": 10.0,
"effects": ["zoom:0,2.0,3.0", "ospeed:1.5"] "effects": ["zoom:0,2.0,3.0", "ospeed:1.5"],
} }
], ],
"settings": { "settings": {"width": 1920, "height": 1080, "frameRate": 25},
"width": 1920, }
"height": 1080,
"frameRate": 25
}
}