mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-07-17 19:48:16 +00:00
背景音乐来源新增 "AI 生成配乐(Sonilo)":将合成完成的视频(未加 BGM) 上传到 Sonilo API,根据画面内容与剪辑节奏生成配乐,音频文件交还给现有 合成流程混音,解说配音音量策略保持不变。 - 独立模块 app/services/sonilo.py,仅在配置 sonilo_api_key 后可用 - 上传前 ffprobe 本地校验时长(接口上限 6 分钟),避免白传计费 - 任何失败只记录日志并回退到随机背景音乐,绝不中断成片任务 - WebUI 选项处提示:视频将上传至 Sonilo API,生成的音乐已获授权、 可商用(以条款为准) - 现有 resource/upload/none 的 BGM 逻辑不做任何改动 Closes #269
164 lines
6.7 KiB
Python
164 lines
6.7 KiB
Python
import base64
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import unittest
|
||
from unittest import mock
|
||
|
||
from app.services import sonilo
|
||
|
||
|
||
def _ndjson_lines(*events):
|
||
return [json.dumps(event) for event in events]
|
||
|
||
|
||
class ConsumeNdjsonStreamTests(unittest.TestCase):
|
||
def test_returns_first_stream_audio_grouped_by_index(self):
|
||
lines = _ndjson_lines(
|
||
{"type": "audio_chunk", "stream_index": 0, "data": base64.b64encode(b"ab").decode()},
|
||
{"type": "audio_chunk", "stream_index": 1, "data": base64.b64encode(b"zz").decode()},
|
||
{"type": "audio_chunk", "stream_index": 0, "data": base64.b64encode(b"cd").decode()},
|
||
{"type": "title", "data": "some title"},
|
||
{"type": "complete"},
|
||
)
|
||
|
||
self.assertEqual(b"abcd", sonilo._consume_ndjson_stream(lines))
|
||
|
||
def test_ignores_blank_and_unparseable_lines(self):
|
||
lines = [
|
||
"",
|
||
" ",
|
||
"not json",
|
||
json.dumps({"type": "audio_chunk", "stream_index": 0, "data": base64.b64encode(b"ok").decode()}),
|
||
json.dumps({"type": "complete"}),
|
||
]
|
||
|
||
self.assertEqual(b"ok", sonilo._consume_ndjson_stream(lines))
|
||
|
||
def test_error_event_raises(self):
|
||
lines = _ndjson_lines(
|
||
{"type": "audio_chunk", "stream_index": 0, "data": base64.b64encode(b"ab").decode()},
|
||
{"type": "error", "message": "boom"},
|
||
)
|
||
|
||
with self.assertRaises(sonilo.SoniloError):
|
||
sonilo._consume_ndjson_stream(lines)
|
||
|
||
def test_missing_complete_event_raises(self):
|
||
lines = _ndjson_lines(
|
||
{"type": "audio_chunk", "stream_index": 0, "data": base64.b64encode(b"ab").decode()},
|
||
)
|
||
|
||
with self.assertRaises(sonilo.SoniloError):
|
||
sonilo._consume_ndjson_stream(lines)
|
||
|
||
def test_complete_without_audio_raises(self):
|
||
lines = _ndjson_lines({"type": "complete"})
|
||
|
||
with self.assertRaises(sonilo.SoniloError):
|
||
sonilo._consume_ndjson_stream(lines)
|
||
|
||
|
||
class GenerateBgmTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self._tmp_dir = tempfile.TemporaryDirectory()
|
||
self.addCleanup(self._tmp_dir.cleanup)
|
||
self.video_path = os.path.join(self._tmp_dir.name, "combined.mp4")
|
||
with open(self.video_path, "wb") as f:
|
||
f.write(b"fake video")
|
||
self.save_path = os.path.join(self._tmp_dir.name, "sonilo_bgm.m4a")
|
||
|
||
def test_returns_empty_without_api_key(self):
|
||
with mock.patch.object(sonilo, "get_api_key", return_value=""):
|
||
self.assertEqual("", sonilo.generate_bgm(self.video_path, self.save_path))
|
||
|
||
def test_returns_empty_when_video_missing(self):
|
||
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"):
|
||
missing = os.path.join(self._tmp_dir.name, "missing.mp4")
|
||
self.assertEqual("", sonilo.generate_bgm(missing, self.save_path))
|
||
|
||
def test_skips_upload_when_duration_exceeds_limit(self):
|
||
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
|
||
mock.patch.object(sonilo, "_probe_video_duration", return_value=361.0), \
|
||
mock.patch.object(sonilo, "_request_video_to_music") as request_mock:
|
||
self.assertEqual("", sonilo.generate_bgm(self.video_path, self.save_path))
|
||
request_mock.assert_not_called()
|
||
|
||
def test_saves_audio_on_success(self):
|
||
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
|
||
mock.patch.object(sonilo, "_probe_video_duration", return_value=60.0), \
|
||
mock.patch.object(sonilo, "_request_video_to_music", return_value=b"audio-bytes"):
|
||
self.assertEqual(self.save_path, sonilo.generate_bgm(self.video_path, self.save_path))
|
||
|
||
with open(self.save_path, "rb") as f:
|
||
self.assertEqual(b"audio-bytes", f.read())
|
||
|
||
def test_request_failure_degrades_to_empty(self):
|
||
with mock.patch.object(sonilo, "get_api_key", return_value="sk-test"), \
|
||
mock.patch.object(sonilo, "_probe_video_duration", return_value=60.0), \
|
||
mock.patch.object(
|
||
sonilo,
|
||
"_request_video_to_music",
|
||
side_effect=sonilo.SoniloError("timeout"),
|
||
):
|
||
self.assertEqual("", sonilo.generate_bgm(self.video_path, self.save_path))
|
||
|
||
self.assertFalse(os.path.exists(self.save_path))
|
||
|
||
|
||
class ResolveBgmPathTests(unittest.TestCase):
|
||
"""任务层的 BGM 解析:Sonilo 模式失败时回退到现有 BGM 逻辑。"""
|
||
|
||
def _make_params(self, bgm_type, bgm_file=""):
|
||
params = mock.Mock()
|
||
params.bgm_type = bgm_type
|
||
params.bgm_file = bgm_file
|
||
return params
|
||
|
||
def test_non_sonilo_type_uses_existing_logic(self):
|
||
from app.services import task
|
||
|
||
params = self._make_params("custom", "/tmp/some_bgm.mp3")
|
||
with mock.patch.object(task.utils, "get_bgm_file", return_value="/tmp/some_bgm.mp3") as get_bgm, \
|
||
mock.patch.object(task.sonilo, "generate_bgm") as generate_bgm:
|
||
result = task._resolve_bgm_path("task-id", params, "/tmp/combined.mp4")
|
||
|
||
self.assertEqual("/tmp/some_bgm.mp3", result)
|
||
get_bgm.assert_called_once_with(bgm_type="custom", bgm_file="/tmp/some_bgm.mp3")
|
||
generate_bgm.assert_not_called()
|
||
|
||
def test_sonilo_type_uses_generated_bgm(self):
|
||
from app.services import task
|
||
|
||
params = self._make_params("sonilo")
|
||
with mock.patch.object(task.utils, "task_dir", return_value="/tmp/task-id"), \
|
||
mock.patch.object(
|
||
task.sonilo, "generate_bgm", return_value="/tmp/task-id/sonilo_bgm.m4a"
|
||
) as generate_bgm, \
|
||
mock.patch.object(task.utils, "get_bgm_file") as get_bgm:
|
||
result = task._resolve_bgm_path("task-id", params, "/tmp/combined.mp4")
|
||
|
||
self.assertEqual("/tmp/task-id/sonilo_bgm.m4a", result)
|
||
generate_bgm.assert_called_once_with(
|
||
"/tmp/combined.mp4", os.path.join("/tmp/task-id", "sonilo_bgm.m4a")
|
||
)
|
||
get_bgm.assert_not_called()
|
||
|
||
def test_sonilo_failure_falls_back_to_random_bgm(self):
|
||
from app.services import task
|
||
|
||
params = self._make_params("sonilo")
|
||
with mock.patch.object(task.utils, "task_dir", return_value="/tmp/task-id"), \
|
||
mock.patch.object(task.sonilo, "generate_bgm", return_value=""), \
|
||
mock.patch.object(
|
||
task.utils, "get_bgm_file", return_value="/resource/songs/output000.mp3"
|
||
) as get_bgm:
|
||
result = task._resolve_bgm_path("task-id", params, "/tmp/combined.mp4")
|
||
|
||
self.assertEqual("/resource/songs/output000.mp3", result)
|
||
get_bgm.assert_called_once_with(bgm_type="random", bgm_file="")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|