初始化提交
This commit is contained in:
1
test/services/__init__.py
Normal file
1
test/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Unit test package for services
|
||||
505
test/services/test_llm.py
Normal file
505
test/services/test_llm.py
Normal file
@@ -0,0 +1,505 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.config import config
|
||||
from app.models.schema import VideoScriptRequest
|
||||
from app.services import llm
|
||||
|
||||
|
||||
class TestScriptPromptOptions(unittest.TestCase):
|
||||
def test_build_script_prompt_appends_advanced_requirements(self):
|
||||
"""
|
||||
高级文案要求只作为附加约束,不替换默认系统提示词。
|
||||
这样普通用户不配置时仍然走稳定默认规则,高级用户也能细化风格。
|
||||
"""
|
||||
prompt = llm.build_script_prompt(
|
||||
video_subject="咖啡",
|
||||
language="zh-CN",
|
||||
paragraph_number=3,
|
||||
video_script_prompt="语气轻松,面向程序员",
|
||||
)
|
||||
|
||||
self.assertIn("# Role: Video Script Generator", prompt)
|
||||
self.assertIn("- video subject: 咖啡", prompt)
|
||||
self.assertIn("- number of paragraphs: 3", prompt)
|
||||
self.assertIn("- language: zh-CN", prompt)
|
||||
self.assertIn("# Additional User Requirements:", prompt)
|
||||
self.assertIn("语气轻松,面向程序员", prompt)
|
||||
|
||||
def test_custom_system_prompt_keeps_runtime_context(self):
|
||||
"""
|
||||
自定义 system prompt 会替换默认脚本规则,但视频主题、语言、段落数
|
||||
仍由服务层统一追加,避免高级用户漏写必要上下文。
|
||||
"""
|
||||
prompt = llm.build_script_prompt(
|
||||
video_subject="露营",
|
||||
language="en",
|
||||
paragraph_number=2,
|
||||
custom_system_prompt="Only write cinematic narration.",
|
||||
)
|
||||
|
||||
self.assertNotIn("# Role: Video Script Generator", prompt)
|
||||
self.assertIn("Only write cinematic narration.", prompt)
|
||||
self.assertIn("- video subject: 露营", prompt)
|
||||
self.assertIn("- number of paragraphs: 2", prompt)
|
||||
self.assertIn("- language: en", prompt)
|
||||
|
||||
def test_generate_script_sends_custom_prompt_to_llm(self):
|
||||
captured = {}
|
||||
|
||||
def fake_generate_response(prompt):
|
||||
captured["prompt"] = prompt
|
||||
return "第一段。\n\n第二段。"
|
||||
|
||||
with patch.object(llm, "_generate_response", side_effect=fake_generate_response):
|
||||
result = llm.generate_script(
|
||||
video_subject="咖啡",
|
||||
language="zh-CN",
|
||||
paragraph_number=2,
|
||||
video_script_prompt="开头更有悬念",
|
||||
)
|
||||
|
||||
self.assertEqual(result, "第一段。\n\n第二段。")
|
||||
self.assertIn("- number of paragraphs: 2", captured["prompt"])
|
||||
self.assertIn("开头更有悬念", captured["prompt"])
|
||||
|
||||
def test_video_script_request_rejects_invalid_advanced_options(self):
|
||||
"""
|
||||
API 请求模型需要限制高级 prompt 参数,避免外部调用绕过 WebUI
|
||||
传入异常段落数或超长提示词,导致模型成本和结果不可控。
|
||||
"""
|
||||
with self.assertRaises(ValidationError):
|
||||
VideoScriptRequest(video_subject="咖啡", paragraph_number=0)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
VideoScriptRequest(
|
||||
video_subject="咖啡",
|
||||
video_script_prompt="x" * (llm.MAX_SCRIPT_PROMPT_LENGTH + 1),
|
||||
)
|
||||
|
||||
|
||||
class TestLiteLLMProvider(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.original_app_config = dict(config.app)
|
||||
|
||||
def tearDown(self):
|
||||
config.app.clear()
|
||||
config.app.update(self.original_app_config)
|
||||
|
||||
def _use_litellm_provider(self, model_name="openai/gpt-4o-mini"):
|
||||
config.app["llm_provider"] = "litellm"
|
||||
config.app["litellm_model_name"] = model_name
|
||||
|
||||
def test_litellm_provider_returns_normalized_text(self):
|
||||
"""
|
||||
验证 LiteLLM provider 的主路径不依赖真实网络和私有 API key。
|
||||
|
||||
这里用 fake module 注入 `sys.modules`,直接覆盖动态 import 的
|
||||
`litellm.completion()`,确保测试稳定覆盖 `_generate_response()` 里的
|
||||
litellm 分支。
|
||||
"""
|
||||
self._use_litellm_provider()
|
||||
|
||||
fake_litellm = types.SimpleNamespace()
|
||||
|
||||
def _completion(**kwargs):
|
||||
self.assertEqual(kwargs["model"], "openai/gpt-4o-mini")
|
||||
self.assertEqual(
|
||||
kwargs["messages"], [{"role": "user", "content": "Say hello"}]
|
||||
)
|
||||
self.assertTrue(kwargs["drop_params"])
|
||||
message = types.SimpleNamespace(content="hello\nworld")
|
||||
choice = types.SimpleNamespace(message=message)
|
||||
return types.SimpleNamespace(choices=[choice])
|
||||
|
||||
fake_litellm.completion = _completion
|
||||
|
||||
with patch.dict(sys.modules, {"litellm": fake_litellm}):
|
||||
result = llm._generate_response("Say hello")
|
||||
|
||||
self.assertEqual(result, "helloworld")
|
||||
|
||||
def test_litellm_provider_requires_model_name(self):
|
||||
self._use_litellm_provider(model_name="")
|
||||
|
||||
result = llm._generate_response("test")
|
||||
|
||||
self.assertIn("Error:", result)
|
||||
self.assertIn("model_name is not set", result)
|
||||
|
||||
def test_litellm_provider_handles_empty_response(self):
|
||||
self._use_litellm_provider()
|
||||
|
||||
fake_litellm = types.SimpleNamespace(
|
||||
completion=lambda **kwargs: types.SimpleNamespace(choices=[])
|
||||
)
|
||||
|
||||
with patch.dict(sys.modules, {"litellm": fake_litellm}):
|
||||
result = llm._generate_response("test")
|
||||
|
||||
self.assertIn("Error:", result)
|
||||
self.assertIn("returned empty response", result)
|
||||
|
||||
def test_litellm_provider_handles_empty_message(self):
|
||||
"""
|
||||
某些 OpenAI-compatible 网关在内容过滤或安全拦截时会返回
|
||||
HTTP 200,但 `choices[0].message` 为 None。这里必须返回
|
||||
可诊断的错误,而不是抛出 AttributeError。
|
||||
"""
|
||||
self._use_litellm_provider()
|
||||
|
||||
fake_litellm = types.SimpleNamespace(
|
||||
completion=lambda **kwargs: types.SimpleNamespace(
|
||||
choices=[types.SimpleNamespace(message=None)]
|
||||
)
|
||||
)
|
||||
|
||||
with patch.dict(sys.modules, {"litellm": fake_litellm}):
|
||||
result = llm._generate_response("test")
|
||||
|
||||
self.assertIn("Error:", result)
|
||||
self.assertIn("returned empty message", result)
|
||||
|
||||
def test_openai_provider_still_uses_existing_path(self):
|
||||
config.app["llm_provider"] = "openai"
|
||||
config.app["openai_api_key"] = ""
|
||||
config.app["openai_base_url"] = "https://api.openai.com/v1"
|
||||
config.app["openai_model_name"] = "gpt-4o-mini"
|
||||
|
||||
result = llm._generate_response("test")
|
||||
|
||||
self.assertIn("Error:", result)
|
||||
self.assertIn("api_key is not set", result)
|
||||
self.assertNotIn("litellm", result.lower())
|
||||
|
||||
def test_grok_provider_still_uses_existing_path(self):
|
||||
config.app["llm_provider"] = "grok"
|
||||
config.app["grok_api_key"] = ""
|
||||
config.app["grok_base_url"] = "https://api.x.ai/v1"
|
||||
config.app["grok_model_name"] = "grok-4.3"
|
||||
|
||||
result = llm._generate_response("test")
|
||||
|
||||
self.assertIn("Error:", result)
|
||||
self.assertIn("api_key is not set", result)
|
||||
self.assertNotIn("litellm", result.lower())
|
||||
|
||||
def _use_ollama_provider(self, base_url=""):
|
||||
config.app["llm_provider"] = "ollama"
|
||||
config.app["ollama_api_key"] = ""
|
||||
config.app["ollama_base_url"] = base_url
|
||||
config.app["ollama_model_name"] = "llama3"
|
||||
|
||||
def _assert_ollama_base_url(self, expected_base_url: str):
|
||||
class FakeCompletions:
|
||||
def create(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
message = types.SimpleNamespace(content="hello\nollama")
|
||||
choice = types.SimpleNamespace(message=message)
|
||||
return types.SimpleNamespace(choices=[choice])
|
||||
|
||||
fake_completions = FakeCompletions()
|
||||
fake_client = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=fake_completions)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
|
||||
patch.object(llm, "ChatCompletion", types.SimpleNamespace),
|
||||
):
|
||||
result = llm._generate_response("Say hello")
|
||||
|
||||
openai_client.assert_called_once_with(
|
||||
api_key="ollama",
|
||||
base_url=expected_base_url,
|
||||
)
|
||||
self.assertEqual(
|
||||
fake_completions.kwargs,
|
||||
{
|
||||
"model": "llama3",
|
||||
"messages": [{"role": "user", "content": "Say hello"}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(result, "helloollama")
|
||||
|
||||
def test_ollama_default_base_url_uses_localhost_outside_container(self):
|
||||
"""
|
||||
普通本机运行时,Ollama 默认仍然使用 localhost,避免影响已有用户。
|
||||
"""
|
||||
self._use_ollama_provider()
|
||||
|
||||
with patch.object(config, "is_running_in_container", return_value=False):
|
||||
self._assert_ollama_base_url("http://localhost:11434/v1")
|
||||
|
||||
def test_ollama_default_base_url_uses_host_gateway_inside_container(self):
|
||||
"""
|
||||
容器内运行时,localhost 指向容器自身;默认改为 host.docker.internal,
|
||||
方便 Docker Desktop 用户访问宿主机上的 Ollama。
|
||||
"""
|
||||
self._use_ollama_provider()
|
||||
|
||||
with (
|
||||
patch.object(config, "is_running_in_container", return_value=True),
|
||||
patch.object(config, "_can_resolve_hostname", return_value=True),
|
||||
):
|
||||
self._assert_ollama_base_url("http://host.docker.internal:11434/v1")
|
||||
|
||||
def test_ollama_default_base_url_falls_back_to_container_gateway(self):
|
||||
"""
|
||||
原生 Linux Docker 里不一定能解析 host.docker.internal。此时使用容器
|
||||
默认网关作为兜底地址,比直接返回不可解析的 hostname 更稳。
|
||||
"""
|
||||
self._use_ollama_provider()
|
||||
|
||||
with (
|
||||
patch.object(config, "is_running_in_container", return_value=True),
|
||||
patch.object(config, "_can_resolve_hostname", return_value=False),
|
||||
patch.object(config, "get_container_default_gateway_ip", return_value="172.17.0.1"),
|
||||
):
|
||||
self._assert_ollama_base_url("http://172.17.0.1:11434/v1")
|
||||
|
||||
def test_ollama_explicit_base_url_takes_precedence(self):
|
||||
"""
|
||||
用户手动配置的 ollama_base_url 优先级最高,不受容器检测影响。
|
||||
"""
|
||||
self._use_ollama_provider(base_url="http://ollama:11434/v1")
|
||||
|
||||
with patch.object(config, "is_running_in_container", return_value=True):
|
||||
self._assert_ollama_base_url("http://ollama:11434/v1")
|
||||
|
||||
def test_mimo_provider_uses_openai_compatible_client(self):
|
||||
"""
|
||||
MiMo 官方接口兼容 OpenAI Chat Completions 协议。这里用 fake OpenAI
|
||||
client 验证 provider 会使用 MiMo 独立配置和默认 base_url,不依赖
|
||||
真实网络或私有 API Key。
|
||||
"""
|
||||
config.app["llm_provider"] = "mimo"
|
||||
config.app["mimo_api_key"] = "mimo-key"
|
||||
config.app["mimo_base_url"] = ""
|
||||
config.app["mimo_model_name"] = ""
|
||||
|
||||
class FakeCompletions:
|
||||
def create(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
message = types.SimpleNamespace(content="hello\nmimo")
|
||||
choice = types.SimpleNamespace(message=message)
|
||||
return types.SimpleNamespace(choices=[choice])
|
||||
|
||||
fake_completions = FakeCompletions()
|
||||
fake_client = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=fake_completions)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
|
||||
patch.object(llm, "ChatCompletion", types.SimpleNamespace),
|
||||
):
|
||||
result = llm._generate_response("Say hello")
|
||||
|
||||
openai_client.assert_called_once_with(
|
||||
api_key="mimo-key",
|
||||
base_url="https://api.xiaomimimo.com/v1",
|
||||
)
|
||||
self.assertEqual(
|
||||
fake_completions.kwargs,
|
||||
{
|
||||
"model": "mimo-v2.5-pro",
|
||||
"messages": [{"role": "user", "content": "Say hello"}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(result, "hellomimo")
|
||||
|
||||
def test_azure_provider_uses_azure_client_directly(self):
|
||||
"""
|
||||
Azure OpenAI 的鉴权、endpoint 和 api-version 都由 AzureOpenAI 客户端处理。
|
||||
这个测试覆盖 issue #892:azure 分支必须直接调用 AzureOpenAI 创建的客户端,
|
||||
不能继续落入普通 OpenAI-compatible 分支,否则会丢失 Azure 专用请求配置。
|
||||
"""
|
||||
config.app["llm_provider"] = "azure"
|
||||
config.app["azure_api_key"] = "azure-key"
|
||||
config.app["azure_base_url"] = "https://example.openai.azure.com"
|
||||
config.app["azure_model_name"] = "gpt-4o-mini"
|
||||
config.app["azure_api_version"] = "2024-02-15-preview"
|
||||
|
||||
class FakeCompletions:
|
||||
def create(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
message = types.SimpleNamespace(content="hello\nazure")
|
||||
choice = types.SimpleNamespace(message=message)
|
||||
return types.SimpleNamespace(choices=[choice])
|
||||
|
||||
fake_completions = FakeCompletions()
|
||||
fake_client = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=fake_completions)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(llm, "AzureOpenAI", return_value=fake_client) as azure_client,
|
||||
patch.object(llm, "OpenAI") as openai_client,
|
||||
patch.object(llm, "ChatCompletion", types.SimpleNamespace),
|
||||
):
|
||||
result = llm._generate_response("Say hello")
|
||||
|
||||
azure_client.assert_called_once_with(
|
||||
api_key="azure-key",
|
||||
api_version="2024-02-15-preview",
|
||||
azure_endpoint="https://example.openai.azure.com",
|
||||
)
|
||||
openai_client.assert_not_called()
|
||||
self.assertEqual(
|
||||
fake_completions.kwargs,
|
||||
{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "Say hello"}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(result, "helloazure")
|
||||
|
||||
def test_g4f_provider_requires_explicit_opt_in(self):
|
||||
"""
|
||||
g4f 存在供应链和稳定性风险,不能因为用户把 provider 写成 g4f
|
||||
就默认加载第三方包并访问逆向接口,必须显式启用。
|
||||
"""
|
||||
config.app["llm_provider"] = "g4f"
|
||||
config.app["enable_g4f"] = False
|
||||
|
||||
result = llm._generate_response("test")
|
||||
|
||||
self.assertIn("Error:", result)
|
||||
self.assertIn("g4f provider is disabled", result)
|
||||
|
||||
def test_g4f_provider_uses_lazy_import_after_opt_in(self):
|
||||
config.app["llm_provider"] = "g4f"
|
||||
config.app["enable_g4f"] = True
|
||||
config.app["g4f_model_name"] = "gpt-3.5-turbo"
|
||||
|
||||
fake_g4f = types.SimpleNamespace()
|
||||
fake_g4f.ChatCompletion = types.SimpleNamespace(
|
||||
create=lambda **kwargs: "hello from g4f"
|
||||
)
|
||||
|
||||
with patch.dict(sys.modules, {"g4f": fake_g4f}):
|
||||
result = llm._generate_response("test")
|
||||
|
||||
self.assertEqual(result, "hello from g4f")
|
||||
|
||||
def test_g4f_provider_reports_missing_optional_dependency(self):
|
||||
config.app["llm_provider"] = "g4f"
|
||||
config.app["enable_g4f"] = True
|
||||
config.app["g4f_model_name"] = "gpt-3.5-turbo"
|
||||
|
||||
with patch.dict(sys.modules, {"g4f": None}):
|
||||
result = llm._generate_response("test")
|
||||
|
||||
self.assertIn("Error:", result)
|
||||
self.assertIn("g4f package is not installed by default", result)
|
||||
|
||||
|
||||
class TestRuntimeEnvironmentDetection(unittest.TestCase):
|
||||
def test_container_detection_ignores_plain_linux_cgroup_file(self):
|
||||
"""
|
||||
普通 Linux 也有 /proc/1/cgroup,不能因为文件存在就判定为容器。
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
cgroup_path = Path(tmp_dir) / "cgroup"
|
||||
cgroup_path.write_text("0::/init.scope\n", encoding="utf-8")
|
||||
|
||||
self.assertFalse(
|
||||
config.is_running_in_container(
|
||||
dockerenv_path=str(Path(tmp_dir) / "missing-dockerenv"),
|
||||
containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
|
||||
cgroup_path=str(cgroup_path),
|
||||
)
|
||||
)
|
||||
|
||||
def test_container_detection_accepts_dockerenv_marker(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
dockerenv_path = Path(tmp_dir) / ".dockerenv"
|
||||
dockerenv_path.write_text("", encoding="utf-8")
|
||||
|
||||
self.assertTrue(
|
||||
config.is_running_in_container(
|
||||
dockerenv_path=str(dockerenv_path),
|
||||
containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
|
||||
cgroup_path=str(Path(tmp_dir) / "missing-cgroup"),
|
||||
)
|
||||
)
|
||||
|
||||
def test_container_detection_accepts_cgroup_container_marker(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
cgroup_path = Path(tmp_dir) / "cgroup"
|
||||
cgroup_path.write_text(
|
||||
"0::/system.slice/docker-abcdef.scope\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
config.is_running_in_container(
|
||||
dockerenv_path=str(Path(tmp_dir) / "missing-dockerenv"),
|
||||
containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
|
||||
cgroup_path=str(cgroup_path),
|
||||
)
|
||||
)
|
||||
|
||||
def test_container_gateway_ip_decodes_default_route(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
route_path = Path(tmp_dir) / "route"
|
||||
route_path.write_text(
|
||||
"Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n"
|
||||
"eth0\t00000000\t010011AC\t0003\t0\t0\t0\t00000000\t0\t0\t0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
config.get_container_default_gateway_ip(str(route_path)),
|
||||
"172.17.0.1",
|
||||
)
|
||||
|
||||
def test_container_gateway_ip_ignores_missing_default_route(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
route_path = Path(tmp_dir) / "route"
|
||||
route_path.write_text(
|
||||
"Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n"
|
||||
"eth0\t0011AC0A\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertEqual(config.get_container_default_gateway_ip(str(route_path)), "")
|
||||
|
||||
|
||||
FOUNDRY_KEY = os.environ.get("ANTHROPIC_FOUNDRY_API_KEY", "")
|
||||
FOUNDRY_BASE = "https://amanrai-test-resource.services.ai.azure.com/anthropic"
|
||||
FOUNDRY_MODEL = "azure_ai/claude-sonnet-4-6"
|
||||
|
||||
|
||||
@unittest.skipUnless(FOUNDRY_KEY, "ANTHROPIC_FOUNDRY_API_KEY not set")
|
||||
class TestLiteLLMLiveIntegration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.original_app_config = dict(config.app)
|
||||
config.app["llm_provider"] = "litellm"
|
||||
config.app["litellm_model_name"] = FOUNDRY_MODEL
|
||||
os.environ["AZURE_AI_API_KEY"] = FOUNDRY_KEY
|
||||
os.environ["AZURE_AI_API_BASE"] = FOUNDRY_BASE
|
||||
|
||||
def tearDown(self):
|
||||
config.app.clear()
|
||||
config.app.update(self.original_app_config)
|
||||
|
||||
def test_live_litellm_completion(self):
|
||||
result = llm._generate_response("What is 2+2? Reply with just the number.")
|
||||
|
||||
self.assertNotIn("Error:", result)
|
||||
self.assertIn("4", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
132
test/services/test_material.py
Normal file
132
test/services/test_material.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.config import config
|
||||
from app.services import material
|
||||
|
||||
|
||||
class TestMaterialTlsVerification(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.original_app_config = dict(config.app)
|
||||
self.original_proxy_config = dict(config.proxy)
|
||||
|
||||
def tearDown(self):
|
||||
config.app.clear()
|
||||
config.app.update(self.original_app_config)
|
||||
config.proxy.clear()
|
||||
config.proxy.update(self.original_proxy_config)
|
||||
|
||||
def test_search_pexels_uses_tls_verification_by_default(self):
|
||||
"""
|
||||
默认路径必须开启 TLS 校验,避免素材 API key 和返回的素材 URL
|
||||
在公共网络或不可信代理环境中被中间人攻击截获或篡改。
|
||||
"""
|
||||
config.app["pexels_api_keys"] = ["pexels-key"]
|
||||
config.app.pop("tls_verify", None)
|
||||
config.proxy.clear()
|
||||
|
||||
fake_response = SimpleNamespace(
|
||||
json=lambda: {
|
||||
"videos": [
|
||||
{
|
||||
"duration": 8,
|
||||
"video_files": [
|
||||
{
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"link": "https://example.com/video.mp4",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
with patch("app.services.material.requests.get", return_value=fake_response) as get:
|
||||
results = material.search_videos_pexels("cat", minimum_duration=1)
|
||||
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertTrue(get.call_args.kwargs["verify"])
|
||||
|
||||
def test_search_pixabay_allows_explicit_tls_disable_for_proxy(self):
|
||||
"""
|
||||
少数企业代理会使用自签证书。该场景必须显式配置关闭 TLS 校验,
|
||||
不能再由代码硬编码默认关闭。
|
||||
"""
|
||||
config.app["pixabay_api_keys"] = ["pixabay-key"]
|
||||
config.app["tls_verify"] = False
|
||||
config.proxy.clear()
|
||||
|
||||
fake_response = SimpleNamespace(
|
||||
json=lambda: {
|
||||
"hits": [
|
||||
{
|
||||
"duration": 8,
|
||||
"videos": {
|
||||
"large": {
|
||||
"width": 1920,
|
||||
"url": "https://example.com/video.mp4",
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
with patch("app.services.material.requests.get", return_value=fake_response) as get:
|
||||
results = material.search_videos_pixabay("cat", minimum_duration=1)
|
||||
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertFalse(get.call_args.kwargs["verify"])
|
||||
|
||||
def test_save_video_uses_tls_verification_by_default(self):
|
||||
config.app.pop("tls_verify", None)
|
||||
config.proxy.clear()
|
||||
|
||||
fake_response = SimpleNamespace(content=b"fake-video")
|
||||
|
||||
class FakeVideoFileClip:
|
||||
duration = 1
|
||||
fps = 24
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch(
|
||||
"app.services.material.requests.get", return_value=fake_response
|
||||
) as get, patch("app.services.material.VideoFileClip", FakeVideoFileClip):
|
||||
video_path = material.save_video(
|
||||
"https://example.com/video.mp4?token=abc", save_dir=temp_dir
|
||||
)
|
||||
|
||||
self.assertTrue(os.path.exists(video_path))
|
||||
self.assertTrue(get.call_args.kwargs["verify"])
|
||||
|
||||
def test_download_videos_accepts_plain_string_concat_mode(self):
|
||||
"""
|
||||
download_videos 可能被服务层或测试直接传入字符串模式,而不是
|
||||
VideoConcatMode 枚举。这里用空搜索词避免真实网络请求,只验证
|
||||
字符串 "random" 不会再因为访问 `.value` 抛 AttributeError。
|
||||
"""
|
||||
result = material.download_videos(
|
||||
task_id="string-concat-mode",
|
||||
search_terms=[],
|
||||
video_contact_mode="random",
|
||||
)
|
||||
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
74
test/services/test_state.py
Normal file
74
test/services/test_state.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.services.state import RedisState
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self, batches):
|
||||
self.batches = batches
|
||||
self.data = {}
|
||||
for key in [key for batch in batches for key in batch]:
|
||||
index = int(key.decode("utf-8").split(":")[-1])
|
||||
self.data[key] = {
|
||||
b"task_id": key,
|
||||
b"state": b"1",
|
||||
b"progress": str(index).encode("utf-8"),
|
||||
}
|
||||
|
||||
def scan(self, cursor, count):
|
||||
batch_index = int(cursor)
|
||||
next_cursor = batch_index + 1
|
||||
if next_cursor >= len(self.batches):
|
||||
next_cursor = 0
|
||||
return next_cursor, self.batches[batch_index]
|
||||
|
||||
def hgetall(self, key):
|
||||
return self.data[key]
|
||||
|
||||
|
||||
class TestRedisState(unittest.TestCase):
|
||||
def _build_state(self, batch_sizes):
|
||||
keys = [f"task:{i}".encode("utf-8") for i in range(sum(batch_sizes))]
|
||||
batches = []
|
||||
offset = 0
|
||||
for batch_size in batch_sizes:
|
||||
batches.append(keys[offset : offset + batch_size])
|
||||
offset += batch_size
|
||||
|
||||
state = RedisState.__new__(RedisState)
|
||||
state._redis = _FakeRedis(batches)
|
||||
return state
|
||||
|
||||
def test_get_all_tasks_paginates_across_scan_batches(self):
|
||||
"""
|
||||
Redis SCAN 分批返回 key 时,分页切片必须按当前批次起始位置计算。
|
||||
|
||||
这个用例复现 PR #890 描述的 18 条任务、page_size=10 场景:
|
||||
第一批 10 条,第二批 8 条。旧逻辑第一页会返回空列表,第二页
|
||||
只返回 2 条;修复后第一页返回 10 条,第二页返回剩余 8 条。
|
||||
"""
|
||||
state = self._build_state([10, 8])
|
||||
|
||||
first_page, first_total = state.get_all_tasks(page=1, page_size=10)
|
||||
second_page, second_total = state.get_all_tasks(page=2, page_size=10)
|
||||
|
||||
self.assertEqual(first_total, 18)
|
||||
self.assertEqual(second_total, 18)
|
||||
self.assertEqual(len(first_page), 10)
|
||||
self.assertEqual(len(second_page), 8)
|
||||
self.assertEqual(
|
||||
[task["task_id"] for task in first_page],
|
||||
[f"task:{i}" for i in range(10)],
|
||||
)
|
||||
self.assertEqual(
|
||||
[task["task_id"] for task in second_page],
|
||||
[f"task:{i}" for i in range(10, 18)],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
93
test/services/test_task.py
Normal file
93
test/services/test_task.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import unittest
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# add project root to python path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.services import task as tm
|
||||
from app.models.schema import MaterialInfo, VideoParams
|
||||
|
||||
resources_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "resources")
|
||||
|
||||
class TestTaskService(unittest.TestCase):
|
||||
def setUp(self):
|
||||
pass
|
||||
|
||||
def tearDown(self):
|
||||
pass
|
||||
|
||||
def test_generate_script_forwards_advanced_prompt_options(self):
|
||||
"""
|
||||
任务生成入口和 WebUI/API 共用 VideoParams。这里验证自动生成文案时,
|
||||
高级提示词参数会继续传到 LLM 服务层,避免只在 /scripts 接口生效。
|
||||
"""
|
||||
params = VideoParams(
|
||||
video_subject="咖啡",
|
||||
video_script="",
|
||||
video_language="zh-CN",
|
||||
paragraph_number=2,
|
||||
video_script_prompt="语气轻松",
|
||||
custom_system_prompt="Only write short narration.",
|
||||
)
|
||||
|
||||
with patch.object(tm.llm, "generate_script", return_value="生成的文案") as generate:
|
||||
result = tm.generate_script("task-id", params)
|
||||
|
||||
self.assertEqual(result, "生成的文案")
|
||||
generate.assert_called_once_with(
|
||||
video_subject="咖啡",
|
||||
language="zh-CN",
|
||||
paragraph_number=2,
|
||||
video_script_prompt="语气轻松",
|
||||
custom_system_prompt="Only write short narration.",
|
||||
)
|
||||
|
||||
def test_task_local_materials(self):
|
||||
task_id = "00000000-0000-0000-0000-000000000000"
|
||||
video_materials=[]
|
||||
for i in range(1, 4):
|
||||
video_materials.append(MaterialInfo(
|
||||
provider="local",
|
||||
url=os.path.join(resources_dir, f"{i}.png"),
|
||||
duration=0
|
||||
))
|
||||
|
||||
params = VideoParams(
|
||||
video_subject="金钱的作用",
|
||||
video_script="金钱不仅是交换媒介,更是社会资源的分配工具。它能满足基本生存需求,如食物和住房,也能提供教育、医疗等提升生活品质的机会。拥有足够的金钱意味着更多选择权,比如职业自由或创业可能。但金钱的作用也有边界,它无法直接购买幸福、健康或真诚的人际关系。过度追逐财富可能导致价值观扭曲,忽视精神层面的需求。理想的状态是理性看待金钱,将其作为实现目标的工具而非终极目的。",
|
||||
video_terms="money importance, wealth and society, financial freedom, money and happiness, role of money",
|
||||
video_aspect="9:16",
|
||||
video_concat_mode="random",
|
||||
video_transition_mode="None",
|
||||
video_clip_duration=3,
|
||||
video_count=1,
|
||||
video_source="local",
|
||||
video_materials=video_materials,
|
||||
video_language="",
|
||||
voice_name="zh-CN-XiaoxiaoNeural-Female",
|
||||
voice_volume=1.0,
|
||||
voice_rate=1.0,
|
||||
bgm_type="random",
|
||||
bgm_file="",
|
||||
bgm_volume=0.2,
|
||||
subtitle_enabled=True,
|
||||
subtitle_position="bottom",
|
||||
custom_position=70.0,
|
||||
font_name="MicrosoftYaHeiBold.ttc",
|
||||
text_fore_color="#FFFFFF",
|
||||
text_background_color=True,
|
||||
font_size=60,
|
||||
stroke_color="#000000",
|
||||
stroke_width=1.5,
|
||||
n_threads=2,
|
||||
paragraph_number=1
|
||||
)
|
||||
result = tm.start(task_id=task_id, params=params)
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
315
test/services/test_video.py
Normal file
315
test/services/test_video.py
Normal file
@@ -0,0 +1,315 @@
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from moviepy import (
|
||||
VideoFileClip,
|
||||
)
|
||||
# add project root to python path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
from app.config import config
|
||||
from app.controllers.manager.base_manager import TaskQueueFullError
|
||||
from app.controllers.manager.memory_manager import InMemoryTaskManager
|
||||
from app.controllers.v1 import video as video_controller
|
||||
from app.models import const
|
||||
from app.models.schema import MaterialInfo
|
||||
from app.services import state as sm
|
||||
from app.services import video as vd
|
||||
from app.utils import utils
|
||||
|
||||
resources_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "resources")
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
def __init__(self):
|
||||
self.headers = {"x-task-id": "test-request"}
|
||||
|
||||
|
||||
class TestSecurityControls(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.original_app_config = dict(config.app)
|
||||
|
||||
def tearDown(self):
|
||||
config.app.clear()
|
||||
config.app.update(self.original_app_config)
|
||||
|
||||
def test_task_query_returns_relative_task_url_without_mutating_state(self):
|
||||
"""
|
||||
endpoint 未显式配置时,任务查询接口不能使用 Host 派生绝对 URL,
|
||||
也不能把展示 URL 回写到任务状态里,否则不同 Host 查询会污染结果。
|
||||
"""
|
||||
task_id = "security-task-url"
|
||||
task_dir = utils.task_dir(task_id)
|
||||
video_path = os.path.join(task_dir, "final-1.mp4")
|
||||
Path(video_path).write_bytes(b"fake-video")
|
||||
config.app["endpoint"] = ""
|
||||
|
||||
try:
|
||||
sm.state.update_task(
|
||||
task_id,
|
||||
state=const.TASK_STATE_COMPLETE,
|
||||
videos=[video_path],
|
||||
combined_videos=[video_path],
|
||||
)
|
||||
|
||||
response = video_controller.get_task(_FakeRequest(), task_id=task_id)
|
||||
|
||||
self.assertEqual(response["data"]["videos"], [f"/tasks/{task_id}/final-1.mp4"])
|
||||
self.assertEqual(sm.state.get_task(task_id)["videos"], [video_path])
|
||||
finally:
|
||||
sm.state.delete_task(task_id)
|
||||
shutil.rmtree(task_dir, ignore_errors=True)
|
||||
|
||||
def test_in_memory_task_manager_rejects_when_queue_is_full(self):
|
||||
"""
|
||||
并发数用尽后,等待队列必须有硬上限。这里用 max_concurrent_tasks=0
|
||||
强制任务进入队列,验证超过 max_queued_tasks 时会拒绝继续入队。
|
||||
"""
|
||||
manager = InMemoryTaskManager(max_concurrent_tasks=0, max_queued_tasks=1)
|
||||
|
||||
manager.add_task(lambda: None)
|
||||
|
||||
with self.assertRaises(TaskQueueFullError):
|
||||
manager.add_task(lambda: None)
|
||||
|
||||
class TestVideoService(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.test_img_path = os.path.join(resources_dir, "1.png")
|
||||
|
||||
def tearDown(self):
|
||||
pass
|
||||
|
||||
def test_preprocess_video(self):
|
||||
if not os.path.exists(self.test_img_path):
|
||||
self.fail(f"test image not found: {self.test_img_path}")
|
||||
|
||||
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
||||
safe_img_path = os.path.join(local_videos_dir, "test-preprocess-1.png")
|
||||
shutil.copy2(self.test_img_path, safe_img_path)
|
||||
|
||||
# test preprocess_video function
|
||||
m = MaterialInfo()
|
||||
m.url = os.path.basename(safe_img_path)
|
||||
m.provider = "local"
|
||||
print(m)
|
||||
|
||||
try:
|
||||
materials = vd.preprocess_video([m], clip_duration=4)
|
||||
print(materials)
|
||||
|
||||
# verify result
|
||||
self.assertIsNotNone(materials)
|
||||
self.assertEqual(len(materials), 1)
|
||||
self.assertTrue(materials[0].url.endswith(".mp4"))
|
||||
|
||||
# moviepy get video info
|
||||
clip = VideoFileClip(materials[0].url)
|
||||
try:
|
||||
print(clip)
|
||||
finally:
|
||||
clip.close()
|
||||
|
||||
# clean generated test video file
|
||||
if os.path.exists(materials[0].url):
|
||||
os.remove(materials[0].url)
|
||||
finally:
|
||||
if os.path.exists(safe_img_path):
|
||||
os.remove(safe_img_path)
|
||||
|
||||
def test_preprocess_video_rejects_material_outside_local_videos(self):
|
||||
"""
|
||||
local 素材路径来自 API 参数,不能允许任意绝对路径进入 MoviePy。
|
||||
这里验证非 local_videos 白名单目录内的路径会被跳过,避免任意文件读取。
|
||||
"""
|
||||
m = MaterialInfo(provider="local", url=self.test_img_path)
|
||||
|
||||
materials = vd.preprocess_video([m], clip_duration=4)
|
||||
|
||||
self.assertEqual(materials, [])
|
||||
|
||||
def test_get_bgm_file_accepts_song_directory_filename(self):
|
||||
"""
|
||||
BGM 列表接口现在只暴露文件名;生成视频时应能把文件名安全解析回
|
||||
resource/songs 白名单目录,保持正常使用路径可用。
|
||||
"""
|
||||
song_dir = utils.song_dir()
|
||||
bgm_path = os.path.join(song_dir, "test-safe-bgm.mp3")
|
||||
Path(bgm_path).write_bytes(b"fake-mp3")
|
||||
|
||||
try:
|
||||
self.assertEqual(vd.get_bgm_file(bgm_file="test-safe-bgm.mp3"), bgm_path)
|
||||
finally:
|
||||
if os.path.exists(bgm_path):
|
||||
os.remove(bgm_path)
|
||||
|
||||
def test_get_bgm_file_accepts_project_relative_song_path(self):
|
||||
"""
|
||||
用户在 WebUI 中可能直接填写 ./resource/songs/xxx.mp3。该路径虽然是
|
||||
项目根目录相对路径,但实际文件仍在 resource/songs 白名单目录内,
|
||||
应该被接受,避免自定义背景音乐被误判为不存在。
|
||||
"""
|
||||
song_dir = utils.song_dir()
|
||||
bgm_path = os.path.join(song_dir, "test-relative-bgm.mp3")
|
||||
Path(bgm_path).write_bytes(b"fake-mp3")
|
||||
|
||||
try:
|
||||
self.assertEqual(
|
||||
vd.get_bgm_file(bgm_file="./resource/songs/test-relative-bgm.mp3"),
|
||||
bgm_path,
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(bgm_path):
|
||||
os.remove(bgm_path)
|
||||
|
||||
def test_get_bgm_file_rejects_path_outside_song_directory(self):
|
||||
"""
|
||||
用户传入的 bgm_file 不能直接作为本地路径打开,否则可能读取系统文件。
|
||||
即使外部文件存在,也必须因为不在 songs 目录内被拒绝。
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3") as temp_bgm:
|
||||
self.assertEqual(vd.get_bgm_file(bgm_file=temp_bgm.name), "")
|
||||
|
||||
def test_get_ffmpeg_binary_uses_configured_env_path(self):
|
||||
"""配置中显式指定 ffmpeg 时,应优先使用该路径。"""
|
||||
with patch.dict(os.environ, {"IMAGEIO_FFMPEG_EXE": "/tmp/custom-ffmpeg"}, clear=True):
|
||||
self.assertEqual(vd.get_ffmpeg_binary(), "/tmp/custom-ffmpeg")
|
||||
|
||||
def test_get_ffmpeg_binary_falls_back_to_imageio_ffmpeg(self):
|
||||
"""
|
||||
Windows 便携包里系统 PATH 可能没有 ffmpeg,但 moviepy 依赖的
|
||||
imageio-ffmpeg 通常会提供可执行文件。这里验证该兜底路径可用。
|
||||
"""
|
||||
fake_imageio_ffmpeg = types.SimpleNamespace(
|
||||
get_ffmpeg_exe=lambda: "/tmp/bundled-ffmpeg"
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch.object(
|
||||
vd.shutil, "which", return_value=None
|
||||
), patch.dict(sys.modules, {"imageio_ffmpeg": fake_imageio_ffmpeg}):
|
||||
self.assertEqual(vd.get_ffmpeg_binary(), "/tmp/bundled-ffmpeg")
|
||||
|
||||
def test_open_video_clip_quietly_suppresses_moviepy_stdout(self):
|
||||
"""
|
||||
MoviePy 2.1.x 的 FFMPEG_VideoReader 会直接向 stdout 打印 metadata
|
||||
和 ffmpeg 命令。项目服务层应屏蔽这类依赖库噪声,避免用户把
|
||||
`audio_found: False` 误判为最终视频没有音频。
|
||||
"""
|
||||
video_path = os.path.join(resources_dir, "1.png.mp4")
|
||||
if not os.path.exists(video_path):
|
||||
self.fail(f"test video not found: {video_path}")
|
||||
|
||||
stdout = StringIO()
|
||||
with redirect_stdout(stdout):
|
||||
clip = vd._open_video_clip_quietly(video_path)
|
||||
|
||||
try:
|
||||
self.assertEqual(stdout.getvalue(), "")
|
||||
self.assertIsNone(clip.audio)
|
||||
self.assertGreater(clip.duration, 0)
|
||||
finally:
|
||||
vd.close_clip(clip)
|
||||
|
||||
def test_combine_videos_closes_audio_clip_when_duration_read_fails(self):
|
||||
"""
|
||||
`combine_videos()` 只需要读取旁白音频时长。即使读取 duration
|
||||
时发生异常,也必须关闭 AudioFileClip,避免文件句柄泄漏。
|
||||
"""
|
||||
|
||||
class _FakeAudioReader:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
class _BrokenAudioClip:
|
||||
def __init__(self):
|
||||
self.reader = _FakeAudioReader()
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
raise RuntimeError("failed to read duration")
|
||||
|
||||
fake_audio_clip = _BrokenAudioClip()
|
||||
|
||||
with patch.object(vd, "AudioFileClip", return_value=fake_audio_clip):
|
||||
with self.assertRaises(RuntimeError):
|
||||
vd.combine_videos(
|
||||
combined_video_path="/tmp/unused-combined.mp4",
|
||||
video_paths=[],
|
||||
audio_file="/tmp/unused-audio.mp3",
|
||||
)
|
||||
|
||||
self.assertTrue(fake_audio_clip.reader.closed)
|
||||
|
||||
def test_combine_videos_handles_none_transition_mode(self):
|
||||
"""
|
||||
Ensure `combine_videos` safely handles
|
||||
`video_transition_mode=None`.
|
||||
"""
|
||||
class _FakeAudioClip:
|
||||
@property
|
||||
def duration(self):
|
||||
return 10.0
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
combined_video_path = os.path.join(temp_dir, "combined.mp4")
|
||||
audio_file = os.path.join(temp_dir, "audio.mp3")
|
||||
|
||||
with patch.object(vd, "AudioFileClip", return_value=_FakeAudioClip()):
|
||||
# Use empty video_paths to avoid heavy video processing while
|
||||
# still exercising transition mode normalization logic.
|
||||
result = vd.combine_videos(
|
||||
combined_video_path=combined_video_path,
|
||||
video_paths=[],
|
||||
audio_file=audio_file,
|
||||
video_transition_mode=None,
|
||||
)
|
||||
self.assertEqual(result, combined_video_path)
|
||||
|
||||
def test_wrap_text(self):
|
||||
"""test text wrapping function"""
|
||||
try:
|
||||
font_path = os.path.join(utils.font_dir(), "STHeitiMedium.ttc")
|
||||
if not os.path.exists(font_path):
|
||||
self.fail(f"font file not found: {font_path}")
|
||||
|
||||
# test english text wrapping
|
||||
test_text_en = "This is a test text for wrapping long sentences in english language"
|
||||
|
||||
wrapped_text_en, text_height_en = vd.wrap_text(
|
||||
text=test_text_en,
|
||||
max_width=300,
|
||||
font=font_path,
|
||||
fontsize=30
|
||||
)
|
||||
print(wrapped_text_en, text_height_en)
|
||||
# verify text is wrapped
|
||||
self.assertIn("\n", wrapped_text_en)
|
||||
|
||||
# test chinese text wrapping
|
||||
test_text_zh = "这是一段用来测试中文长句换行的文本内容,应该会根据宽度限制进行换行处理"
|
||||
wrapped_text_zh, text_height_zh = vd.wrap_text(
|
||||
text=test_text_zh,
|
||||
max_width=300,
|
||||
font=font_path,
|
||||
fontsize=30
|
||||
)
|
||||
print(wrapped_text_zh, text_height_zh)
|
||||
# verify chinese text is wrapped
|
||||
self.assertIn("\n", wrapped_text_zh)
|
||||
except Exception as e:
|
||||
self.fail(f"test wrap_text failed: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
487
test/services/test_voice.py
Normal file
487
test/services/test_voice.py
Normal file
@@ -0,0 +1,487 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import unittest
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
# add project root to python path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.utils import utils
|
||||
from app.services import voice as vs
|
||||
from app.services import task as task_service
|
||||
from pydub import AudioSegment
|
||||
|
||||
temp_dir = utils.storage_dir("temp")
|
||||
|
||||
text_en = """
|
||||
What is the meaning of life?
|
||||
This question has puzzled philosophers, scientists, and thinkers of all kinds for centuries.
|
||||
Throughout history, various cultures and individuals have come up with their interpretations and beliefs around the purpose of life.
|
||||
Some say it's to seek happiness and self-fulfillment, while others believe it's about contributing to the welfare of others and making a positive impact in the world.
|
||||
Despite the myriad of perspectives, one thing remains clear: the meaning of life is a deeply personal concept that varies from one person to another.
|
||||
It's an existential inquiry that encourages us to reflect on our values, desires, and the essence of our existence.
|
||||
"""
|
||||
|
||||
text_zh = """
|
||||
预计未来3天深圳冷空气活动频繁,未来两天持续阴天有小雨,出门带好雨具;
|
||||
10-11日持续阴天有小雨,日温差小,气温在13-17℃之间,体感阴凉;
|
||||
12日天气短暂好转,早晚清凉;
|
||||
"""
|
||||
|
||||
voice_rate=1.0
|
||||
voice_volume=1.0
|
||||
|
||||
class TestVoiceService(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
|
||||
def tearDown(self):
|
||||
self.loop.close()
|
||||
|
||||
def test_siliconflow(self):
|
||||
# SiliconFlow 的 API Key 存在 [siliconflow].api_key 中,运行时代码也是从
|
||||
# config.siliconflow 读取;这里必须使用同一配置源,避免正确配置凭据时
|
||||
# 测试仍然被误跳过。
|
||||
if not vs.config.siliconflow.get("api_key"):
|
||||
self.skipTest("siliconflow_api_key is not configured")
|
||||
|
||||
voice_name = "siliconflow:FunAudioLLM/CosyVoice2-0.5B:alex-Male"
|
||||
voice_name = vs.parse_voice_name(voice_name)
|
||||
|
||||
async def _do():
|
||||
parts = voice_name.split(":")
|
||||
if len(parts) >= 3:
|
||||
model = parts[1]
|
||||
# 移除性别后缀,例如 "alex-Male" -> "alex"
|
||||
voice_with_gender = parts[2]
|
||||
voice = voice_with_gender.split("-")[0]
|
||||
# 构建完整的voice参数,格式为 "model:voice"
|
||||
full_voice = f"{model}:{voice}"
|
||||
voice_file = f"{temp_dir}/tts-siliconflow-{voice}.mp3"
|
||||
subtitle_file = f"{temp_dir}/tts-siliconflow-{voice}.srt"
|
||||
sub_maker = vs.siliconflow_tts(
|
||||
text=text_zh, model=model, voice=full_voice, voice_file=voice_file, voice_rate=voice_rate, voice_volume=voice_volume
|
||||
)
|
||||
if not sub_maker:
|
||||
self.fail("siliconflow tts failed")
|
||||
vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
|
||||
audio_duration = vs.get_audio_duration(sub_maker)
|
||||
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
|
||||
else:
|
||||
self.fail("siliconflow invalid voice name")
|
||||
|
||||
self.loop.run_until_complete(_do())
|
||||
|
||||
def test_azure_tts_v1(self):
|
||||
voice_name = "zh-CN-XiaoyiNeural-Female"
|
||||
voice_name = vs.parse_voice_name(voice_name)
|
||||
print(voice_name)
|
||||
|
||||
voice_file = f"{temp_dir}/tts-azure-v1-{voice_name}.mp3"
|
||||
subtitle_file = f"{temp_dir}/tts-azure-v1-{voice_name}.srt"
|
||||
sub_maker = vs.azure_tts_v1(
|
||||
text=text_zh, voice_name=voice_name, voice_file=voice_file, voice_rate=voice_rate
|
||||
)
|
||||
if not sub_maker:
|
||||
self.fail("azure tts v1 failed")
|
||||
vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
|
||||
audio_duration = vs.get_audio_duration(sub_maker)
|
||||
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
|
||||
|
||||
def test_azure_tts_v1_supports_legacy_edge_tts_without_boundary(self):
|
||||
"""
|
||||
验证 Azure TTS V1 在旧版 edge_tts 依赖残留时仍可继续工作。
|
||||
|
||||
这个回归场景对应 Windows 便携包更新失败后,现场环境还停留在旧版
|
||||
edge_tts 的情况:
|
||||
1. `Communicate.__init__()` 不接受 `boundary`
|
||||
2. 只有异步 `stream()`,没有 `stream_sync()`
|
||||
"""
|
||||
|
||||
class _LegacyCommunicate:
|
||||
def __init__(self, text, voice, rate="+0%"):
|
||||
self.text = text
|
||||
self.voice = voice
|
||||
self.rate = rate
|
||||
|
||||
async def stream(self):
|
||||
yield {"type": "audio", "data": b"legacy-audio"}
|
||||
yield {
|
||||
"type": "WordBoundary",
|
||||
"offset": 0,
|
||||
"duration": 10000000,
|
||||
"text": "legacy",
|
||||
}
|
||||
|
||||
class _FakeSubMaker:
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def feed(self, chunk):
|
||||
self.events.append(chunk)
|
||||
|
||||
def get_srt(self):
|
||||
if not self.events:
|
||||
return ""
|
||||
return "1\n00:00:00,000 --> 00:00:01,000\nlegacy\n"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
|
||||
vs.edge_tts, "Communicate", _LegacyCommunicate
|
||||
), patch.object(vs.edge_tts, "SubMaker", _FakeSubMaker):
|
||||
voice_file = str(Path(tmp_dir) / "legacy-edge-tts.mp3")
|
||||
sub_maker = vs.azure_tts_v1(
|
||||
text="legacy edge tts compatibility",
|
||||
voice_name="zh-CN-XiaoyiNeural-Female",
|
||||
voice_file=voice_file,
|
||||
voice_rate=1.0,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(sub_maker)
|
||||
self.assertEqual(Path(voice_file).read_bytes(), b"legacy-audio")
|
||||
self.assertEqual(len(sub_maker.events), 1)
|
||||
self.assertEqual(sub_maker.events[0]["type"], "WordBoundary")
|
||||
|
||||
def test_azure_tts_v1_times_out_hanging_stream_sync(self):
|
||||
"""
|
||||
验证 Azure TTS V1 在 edge_tts 同步流卡住时能够快速失败。
|
||||
|
||||
真实现场里,网络异常、服务端限流、voice 语言与文本不匹配时,
|
||||
`stream_sync()` 可能长时间不返回,导致 WebUI 任务只停在
|
||||
`start, voice name...`。这里用阻塞的 fake stream 复现该场景,
|
||||
确认超时保护会让函数结束并返回 None。
|
||||
"""
|
||||
|
||||
class _HangingCommunicate:
|
||||
def __init__(self, text, voice, rate="+0%", boundary=None):
|
||||
self.text = text
|
||||
self.voice = voice
|
||||
self.rate = rate
|
||||
self.boundary = boundary
|
||||
|
||||
def stream_sync(self):
|
||||
time.sleep(10)
|
||||
yield {"type": "audio", "data": b"unreachable"}
|
||||
|
||||
class _FakeSubMaker:
|
||||
def feed(self, chunk):
|
||||
return None
|
||||
|
||||
def get_srt(self):
|
||||
return ""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
|
||||
vs.edge_tts, "Communicate", _HangingCommunicate
|
||||
), patch.object(vs.edge_tts, "SubMaker", _FakeSubMaker), patch.object(
|
||||
vs.config,
|
||||
"app",
|
||||
dict(vs.config.app, edge_tts_timeout=0.05),
|
||||
):
|
||||
voice_file = Path(tmp_dir) / "hanging-edge-tts.mp3"
|
||||
started_at = time.monotonic()
|
||||
sub_maker = vs.azure_tts_v1(
|
||||
text="帮我生成一个花开花落的视频",
|
||||
voice_name="en-AU-NatashaNeural-Female",
|
||||
voice_file=str(voice_file),
|
||||
voice_rate=1.0,
|
||||
)
|
||||
elapsed = time.monotonic() - started_at
|
||||
self.assertFalse(voice_file.exists())
|
||||
|
||||
self.assertIsNone(sub_maker)
|
||||
self.assertLess(elapsed, 2)
|
||||
|
||||
def test_azure_tts_v2(self):
|
||||
if not vs.config.azure.get("speech_key") or not vs.config.azure.get("speech_region"):
|
||||
self.skipTest("Azure speech key or region is not configured")
|
||||
|
||||
voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female"
|
||||
voice_name = vs.parse_voice_name(voice_name)
|
||||
print(voice_name)
|
||||
|
||||
async def _do():
|
||||
voice_file = f"{temp_dir}/tts-azure-v2-{voice_name}.mp3"
|
||||
subtitle_file = f"{temp_dir}/tts-azure-v2-{voice_name}.srt"
|
||||
sub_maker = vs.azure_tts_v2(
|
||||
text=text_zh, voice_name=voice_name, voice_file=voice_file
|
||||
)
|
||||
if not sub_maker:
|
||||
self.fail("azure tts v2 failed")
|
||||
vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
|
||||
audio_duration = vs.get_audio_duration(sub_maker)
|
||||
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
|
||||
|
||||
self.loop.run_until_complete(_do())
|
||||
|
||||
def test_gemini_tts_uses_legacy_submaker_fields(self):
|
||||
"""
|
||||
验证 Gemini TTS 在 edge_tts 7.x 环境下仍会返回项目兼容的字幕结构,
|
||||
并且可以被 `subtitle_provider=edge` 的字幕生成链路直接消费,
|
||||
避免再次回退 Whisper。
|
||||
"""
|
||||
|
||||
class _InlineData:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
class _Part:
|
||||
def __init__(self, data):
|
||||
self.inline_data = _InlineData(data)
|
||||
|
||||
class _Content:
|
||||
def __init__(self, data):
|
||||
self.parts = [_Part(data)]
|
||||
|
||||
class _Candidate:
|
||||
def __init__(self, data):
|
||||
self.content = _Content(data)
|
||||
|
||||
class _Response:
|
||||
def __init__(self, data):
|
||||
self.candidates = [_Candidate(data)]
|
||||
|
||||
class _FakeModel:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def generate_content(self, contents, generation_config):
|
||||
tone = (
|
||||
AudioSegment.silent(duration=1800)
|
||||
.set_frame_rate(24000)
|
||||
.set_channels(1)
|
||||
.set_sample_width(2)
|
||||
)
|
||||
return _Response(tone.raw_data)
|
||||
|
||||
voice_file = f"{temp_dir}/tts-gemini-Zephyr.mp3"
|
||||
subtitle_file = f"{temp_dir}/tts-gemini-Zephyr.srt"
|
||||
text = "Gemini subtitle generation should work now. Testing multiple lines."
|
||||
|
||||
with patch("google.generativeai.configure"), patch(
|
||||
"google.generativeai.GenerativeModel", _FakeModel
|
||||
), patch.object(vs.config, "app", dict(vs.config.app, gemini_api_key="test-key")):
|
||||
sub_maker = vs.gemini_tts(
|
||||
text=text,
|
||||
voice_name="Zephyr",
|
||||
voice_rate=1.0,
|
||||
voice_file=voice_file,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(sub_maker)
|
||||
self.assertEqual(
|
||||
getattr(sub_maker, "subs", []),
|
||||
["Gemini subtitle generation should work now", "Testing multiple lines"],
|
||||
)
|
||||
self.assertEqual(len(getattr(sub_maker, "offset", [])), 2)
|
||||
self.assertEqual(sub_maker.offset[0][0], 0)
|
||||
self.assertLess(sub_maker.offset[0][1], sub_maker.offset[1][1])
|
||||
|
||||
vs.create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file)
|
||||
subtitle_content = Path(subtitle_file).read_text(encoding="utf-8")
|
||||
self.assertIn("Gemini subtitle generation should work now", subtitle_content)
|
||||
self.assertIn("Testing multiple lines", subtitle_content)
|
||||
|
||||
def test_mimo_tts_uses_openai_compatible_audio_response(self):
|
||||
"""
|
||||
验证 Xiaomi MiMo TTS 可以消费 OpenAI-compatible 的音频响应结构。
|
||||
|
||||
这里用 fake OpenAI client 和 fake AudioSegment 覆盖真实网络与 ffmpeg,
|
||||
确认运行时代码会把待合成文本放到 assistant message,并把返回的
|
||||
base64 WAV 音频导出到项目后续流程使用的音频文件。
|
||||
"""
|
||||
|
||||
class _FakeAudio:
|
||||
def __init__(self):
|
||||
self.data = base64.b64encode(b"RIFF-fake-wav").decode("utf-8")
|
||||
|
||||
class _FakeMessage:
|
||||
def __init__(self):
|
||||
self.audio = _FakeAudio()
|
||||
|
||||
class _FakeChoice:
|
||||
def __init__(self):
|
||||
self.message = _FakeMessage()
|
||||
|
||||
class _FakeCompletion:
|
||||
def __init__(self):
|
||||
self.choices = [_FakeChoice()]
|
||||
|
||||
class _FakeCompletions:
|
||||
def create(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
return _FakeCompletion()
|
||||
|
||||
class _FakeAudioSegment:
|
||||
def __len__(self):
|
||||
return 1800
|
||||
|
||||
def export(self, output_file, format):
|
||||
Path(output_file).write_bytes(b"fake-mp3")
|
||||
|
||||
fake_completions = _FakeCompletions()
|
||||
fake_client = SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=fake_completions)
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
|
||||
vs,
|
||||
"OpenAI",
|
||||
return_value=fake_client,
|
||||
) as openai_client, patch(
|
||||
"pydub.AudioSegment.from_file",
|
||||
return_value=_FakeAudioSegment(),
|
||||
), patch.object(
|
||||
vs.config,
|
||||
"app",
|
||||
dict(
|
||||
vs.config.app,
|
||||
mimo_api_key="mimo-key",
|
||||
mimo_base_url="https://api.xiaomimimo.com/v1",
|
||||
mimo_tts_model_name="mimo-v2.5-tts",
|
||||
mimo_tts_style_prompt="用清晰的中文旁白朗读。",
|
||||
),
|
||||
):
|
||||
voice_file = str(Path(tmp_dir) / "mimo-tts.mp3")
|
||||
sub_maker = vs.mimo_tts(
|
||||
text="小米语音合成测试。第二句话。",
|
||||
voice_name="冰糖",
|
||||
voice_rate=1.0,
|
||||
voice_file=voice_file,
|
||||
voice_volume=1.0,
|
||||
)
|
||||
generated_audio = Path(voice_file).read_bytes()
|
||||
|
||||
openai_client.assert_called_once_with(
|
||||
api_key="mimo-key",
|
||||
base_url="https://api.xiaomimimo.com/v1",
|
||||
)
|
||||
self.assertEqual(fake_completions.kwargs["model"], "mimo-v2.5-tts")
|
||||
self.assertEqual(
|
||||
fake_completions.kwargs["messages"],
|
||||
[
|
||||
{"role": "user", "content": "用清晰的中文旁白朗读。"},
|
||||
{"role": "assistant", "content": "小米语音合成测试。第二句话。"},
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
fake_completions.kwargs["audio"],
|
||||
{"format": "wav", "voice": "冰糖"},
|
||||
)
|
||||
self.assertEqual(generated_audio, b"fake-mp3")
|
||||
self.assertIsNotNone(sub_maker)
|
||||
self.assertEqual(getattr(sub_maker, "subs", []), ["小米语音合成测试", "第二句话"])
|
||||
self.assertEqual(len(getattr(sub_maker, "offset", [])), 2)
|
||||
|
||||
def test_generate_subtitle_keeps_edge_provider_for_gemini_legacy_submaker(self):
|
||||
"""
|
||||
验证 Gemini TTS 返回的 legacy 字幕结构在 edge provider 下可以直接产出
|
||||
SRT,不会因为匹配失败而回退到 Whisper。
|
||||
"""
|
||||
script = "Gemini subtitle generation should work now. Testing multiple lines."
|
||||
sub_maker = vs.populate_legacy_submaker_with_full_text(
|
||||
vs.ensure_legacy_submaker_fields(vs.SubMaker()),
|
||||
script,
|
||||
2.4,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
|
||||
task_service.config,
|
||||
"app",
|
||||
dict(task_service.config.app, subtitle_provider="edge"),
|
||||
), patch("app.services.subtitle.create") as whisper_create, patch(
|
||||
"app.utils.utils.task_dir",
|
||||
lambda tid="": str(Path(tmp_dir) / tid) if tid else str(Path(tmp_dir)),
|
||||
):
|
||||
task_id = "gemini-subtitle-edge-task"
|
||||
Path(tmp_dir, task_id).mkdir(parents=True, exist_ok=True)
|
||||
subtitle_path = task_service.generate_subtitle(
|
||||
task_id=task_id,
|
||||
params=type("Params", (), {"subtitle_enabled": True})(),
|
||||
video_script=script,
|
||||
sub_maker=sub_maker,
|
||||
audio_file="",
|
||||
)
|
||||
|
||||
self.assertTrue(subtitle_path.endswith("subtitle.srt"))
|
||||
self.assertTrue(Path(subtitle_path).exists())
|
||||
self.assertFalse(whisper_create.called)
|
||||
subtitle_content = Path(subtitle_path).read_text(encoding="utf-8")
|
||||
self.assertIn("Gemini subtitle generation should work now", subtitle_content)
|
||||
self.assertIn("Testing multiple lines", subtitle_content)
|
||||
|
||||
def test_script_split_keeps_thousand_separator_comma(self):
|
||||
"""
|
||||
Edge TTS 会把 "1,000 years" 作为连续文本返回。脚本断句时不能把
|
||||
数字中间的英文逗号当成句子边界,否则字幕聚合会出现 issue #894
|
||||
里的 sub_items 数量少于 script_lines,并错误回退 Whisper。
|
||||
"""
|
||||
text = (
|
||||
"It takes about 1,000 years for a single drop of water to finish "
|
||||
"the whole trip!"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
utils.split_string_by_punctuations(text),
|
||||
[
|
||||
(
|
||||
"It takes about 1,000 years for a single drop of water to finish "
|
||||
"the whole trip"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_edge_cue_aggregation_handles_thousand_separator_comma(self):
|
||||
"""
|
||||
复现 issue #894 的关键形态:Edge cues 中最后一句作为连续文本返回,
|
||||
包含 `1,000 years`。脚本断句必须与 cues 聚合结果一致,不能把它
|
||||
拆成两条字幕。
|
||||
"""
|
||||
text = (
|
||||
"The ocean isn't just sitting stil, it moves around the world like a massive "
|
||||
"amusement park ride! Cold water at the North and South Poles sinks to the "
|
||||
"bottom because it is heavy and salty. At the same time, warm water from the "
|
||||
"sunny equator flows along the top to take its place. This creates a giant "
|
||||
"underwater conveyor belt that travels all the way around the Earth. It takes "
|
||||
"about 1,000 years for a single drop of water to finish the whole trip!"
|
||||
)
|
||||
script_lines = utils.split_string_by_punctuations(text)
|
||||
cues = []
|
||||
for index, line in enumerate(script_lines):
|
||||
# Edge 的 cue content 经常没有脚本里的空格和标点布局,这里去掉空格
|
||||
# 来模拟更严格的匹配场景。
|
||||
cues.append(
|
||||
SimpleNamespace(
|
||||
content=line.replace(" ", ""),
|
||||
start=timedelta(seconds=index),
|
||||
end=timedelta(seconds=index + 0.8),
|
||||
)
|
||||
)
|
||||
sub_maker = SimpleNamespace(cues=cues)
|
||||
|
||||
sub_items = vs._build_subtitle_items_from_edge_cues(sub_maker, script_lines)
|
||||
|
||||
self.assertEqual(len(sub_items), len(script_lines))
|
||||
self.assertIn("1,000 years", sub_items[-1])
|
||||
|
||||
def test_convert_rate_to_percent_signs_zero_rate(self):
|
||||
# Rates near but not exactly 1.0 round to 0 percent. edge-tts rejects
|
||||
# an unsigned "0%" (ValueError: Invalid rate '0%'), so the helper must
|
||||
# emit a sign-prefixed "+0%". Regression test for that crash.
|
||||
self.assertEqual(vs.convert_rate_to_percent(1.0), "+0%")
|
||||
self.assertEqual(vs.convert_rate_to_percent(1.004), "+0%")
|
||||
self.assertEqual(vs.convert_rate_to_percent(0.997), "+0%")
|
||||
self.assertEqual(vs.convert_rate_to_percent(1.5), "+50%")
|
||||
self.assertEqual(vs.convert_rate_to_percent(0.8), "-20%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v1
|
||||
# python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v2
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user