mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-07-17 11:38:09 +00:00
Merge pull request #270 from cindyxu1030/feat/sonilo-bgm
feat(bgm): 新增 Sonilo AI 配乐模式(可选,默认关闭)
This commit is contained in:
commit
d0216fcb04
1
.gitignore
vendored
1
.gitignore
vendored
@ -54,6 +54,7 @@ tests/*
|
||||
!tests/test_generate_narration_script_documentary_unittest.py
|
||||
!tests/test_generate_script_docu_unittest.py
|
||||
!tests/test_streamlit_widget_session_state.py
|
||||
!tests/test_sonilo_bgm_unittest.py
|
||||
|
||||
docs/reddit-community
|
||||
docs/wechat-0.8
|
||||
|
||||
273
app/services/sonilo.py
Normal file
273
app/services/sonilo.py
Normal file
@ -0,0 +1,273 @@
|
||||
"""
|
||||
Sonilo (https://sonilo.com) AI 配乐(BGM)集成 —— 可选功能,默认关闭。
|
||||
|
||||
将合成完成的视频(未加 BGM)上传到 Sonilo API(`POST /v1/video-to-music`),
|
||||
根据画面内容与剪辑节奏生成一段背景音乐,作为普通音频文件交还给现有的
|
||||
合成流程混音。生成的音乐已获授权、可商用(以条款为准)。
|
||||
|
||||
设计约束(完全不影响现有 BGM 逻辑):
|
||||
* 默认关闭。仅当用户在 WebUI 中把背景音乐来源切换为 Sonilo,且配置了
|
||||
Sonilo API Key(config.toml 的 `sonilo_api_key`,或环境变量
|
||||
`SONILO_API_KEY` 兜底)时才会启用。
|
||||
* 本模块只负责生成音频文件;音量、淡出、循环与混音全部复用
|
||||
app/services/generate_video.py 中现有的音频处理逻辑,解说配音的
|
||||
音量压制策略保持不变。
|
||||
* 任何失败(超时、HTTP 错误、流中断、时长超限)都只记录日志并返回
|
||||
空字符串,由调用方回退到现有的 BGM 逻辑,绝不中断成片任务。
|
||||
* 上传属于计费操作,上传前先用 ffprobe 在本地校验视频时长(接口
|
||||
目前拒绝超过 6 分钟的视频),避免白传一次注定被拒绝的成片。
|
||||
|
||||
接口返回 NDJSON 事件流:`audio_chunk`(base64 音频分片,按 stream_index
|
||||
分组)、`title`、`complete`(成功终止事件)与 `error`(失败终止事件)。
|
||||
进度事件与无法解析的行一律忽略。生成的音频为 AAC 编码的 .m4a 文件。
|
||||
|
||||
配置(config.toml 的 [app] 段):
|
||||
sonilo_api_key = "..." # 必填,启用开关
|
||||
# sonilo_base_url = "https://api.sonilo.com"
|
||||
# sonilo_timeout_seconds = 600
|
||||
# sonilo_bgm_prompt = "" # 可选:配乐风格提示
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Iterable, Optional
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
|
||||
DEFAULT_BASE_URL = "https://api.sonilo.com"
|
||||
VIDEO_TO_MUSIC_PATH = "/v1/video-to-music"
|
||||
# 后端生成接口的读超时约为 600 秒。生成一旦开始就会计费,客户端过早超时
|
||||
# 只会浪费一次已经付费的请求,所以默认读超时与后端保持一致,并允许覆盖。
|
||||
DEFAULT_TIMEOUT_SECONDS = 600
|
||||
_CONNECT_TIMEOUT_SECONDS = 15
|
||||
# 接口目前拒绝超过 6 分钟的视频;上传前先在本地校验时长。
|
||||
MAX_VIDEO_DURATION_SECONDS = 360
|
||||
|
||||
|
||||
class SoniloError(Exception):
|
||||
"""Sonilo 配乐生成失败。"""
|
||||
|
||||
|
||||
def get_api_key() -> str:
|
||||
"""返回配置的 Sonilo API Key(优先 config.toml,其次环境变量)。"""
|
||||
api_key = config.app.get("sonilo_api_key", "") or os.getenv("SONILO_API_KEY", "")
|
||||
return str(api_key).strip()
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""仅当配置了 Sonilo API Key 时返回 True。"""
|
||||
return bool(get_api_key())
|
||||
|
||||
|
||||
def generate_bgm(video_path: str, save_path: str) -> str:
|
||||
"""
|
||||
上传合成完成的视频(未加 BGM)到 Sonilo,生成配乐并保存到
|
||||
`save_path`(.m4a)。
|
||||
|
||||
成功时返回音频文件路径,任何失败都返回空字符串,由调用方回退到
|
||||
现有的 BGM 逻辑。本函数绝不抛出异常 —— 配乐问题绝不能中断成片任务。
|
||||
"""
|
||||
if not is_enabled():
|
||||
logger.warning("Sonilo 配乐已跳过: 未配置 API Key")
|
||||
return ""
|
||||
|
||||
if not video_path or not os.path.isfile(video_path):
|
||||
logger.warning(f"Sonilo 配乐已跳过: 视频文件不存在: {video_path}")
|
||||
return ""
|
||||
|
||||
# 上传即计费,先在本地校验时长,避免白传一次注定被拒绝的成片。
|
||||
duration = _probe_video_duration(video_path)
|
||||
if duration and duration > MAX_VIDEO_DURATION_SECONDS:
|
||||
logger.warning(
|
||||
f"Sonilo 配乐已跳过: 视频时长 {duration:.1f}s 超过接口上限 "
|
||||
f"{MAX_VIDEO_DURATION_SECONDS}s"
|
||||
)
|
||||
return ""
|
||||
|
||||
try:
|
||||
audio = _request_video_to_music(video_path)
|
||||
except Exception as e:
|
||||
# 任何失败(超时、HTTP 错误、流中断)都降级,由调用方回退到
|
||||
# 现有 BGM 逻辑,绝不让配乐问题中断成片任务。
|
||||
logger.error(f"Sonilo 配乐生成失败: {str(e)}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(audio)
|
||||
except OSError as e:
|
||||
logger.error(f"Sonilo 配乐文件保存失败: {str(e)}")
|
||||
return ""
|
||||
|
||||
logger.success(f"Sonilo 配乐已生成: {save_path}")
|
||||
return save_path
|
||||
|
||||
|
||||
def _get_ffprobe_binary() -> str:
|
||||
"""与 generate_video 保持一致的 ffprobe 查找逻辑(环境变量优先)。"""
|
||||
for env_name in ("NARRATO_FFPROBE_EXE", "IMAGEIO_FFPROBE_EXE"):
|
||||
candidate = os.environ.get(env_name, "").strip()
|
||||
if candidate and os.path.isfile(candidate):
|
||||
return candidate
|
||||
return "ffprobe"
|
||||
|
||||
|
||||
def _probe_video_duration(video_path: str) -> float:
|
||||
"""
|
||||
尽力而为的本地 ffprobe 时长探测。ffprobe 不可用、超时或输出无法解析
|
||||
时返回 0.0,交给后端做最终校验。
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
_get_ffprobe_binary(),
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
video_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return 0.0
|
||||
if result.returncode != 0:
|
||||
return 0.0
|
||||
try:
|
||||
return float(json.loads(result.stdout)["format"]["duration"])
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _error_detail(body: str) -> str:
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return body
|
||||
if isinstance(parsed, dict):
|
||||
detail = parsed.get("detail") or parsed.get("error") or parsed.get("message")
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
return detail.strip()
|
||||
return body
|
||||
|
||||
|
||||
def _http_error_message(status_code: int, body: str) -> str:
|
||||
detail = _error_detail(body)
|
||||
if status_code == 401:
|
||||
return "Sonilo API Key 无效,请检查配置"
|
||||
if status_code == 402:
|
||||
return detail or "Sonilo 账户余额不足"
|
||||
if status_code == 413:
|
||||
return f"视频文件过大: {detail}"
|
||||
if status_code == 429:
|
||||
return f"触发 Sonilo 频率限制: {detail}"
|
||||
return f"Sonilo 接口错误 ({status_code}): {detail}"
|
||||
|
||||
|
||||
def _get_timeout_seconds() -> float:
|
||||
try:
|
||||
return float(
|
||||
config.app.get("sonilo_timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def _request_video_to_music(video_path: str) -> bytes:
|
||||
base_url = str(config.app.get("sonilo_base_url", "") or DEFAULT_BASE_URL).rstrip(
|
||||
"/"
|
||||
)
|
||||
timeout_seconds = _get_timeout_seconds()
|
||||
|
||||
prompt = str(config.app.get("sonilo_bgm_prompt", "") or "").strip()
|
||||
data: Optional[dict] = {"prompt": prompt} if prompt else None
|
||||
headers = {"Authorization": f"Bearer {get_api_key()}"}
|
||||
|
||||
logger.info(
|
||||
f"正在使用 Sonilo 生成配乐, 视频: {video_path}, "
|
||||
f"读超时: {timeout_seconds:.0f}s"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(video_path, "rb") as video_file:
|
||||
files = {
|
||||
"video": (os.path.basename(video_path), video_file, "video/mp4"),
|
||||
}
|
||||
# 生成接口非幂等(生成即计费),失败不做自动重试,直接降级。
|
||||
with requests.post(
|
||||
f"{base_url}{VIDEO_TO_MUSIC_PATH}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
files=files,
|
||||
stream=True,
|
||||
timeout=(_CONNECT_TIMEOUT_SECONDS, timeout_seconds),
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
body = response.content.decode("utf-8", errors="replace")
|
||||
raise SoniloError(
|
||||
_http_error_message(response.status_code, body)
|
||||
)
|
||||
return _consume_ndjson_stream(
|
||||
response.iter_lines(decode_unicode=True)
|
||||
)
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise SoniloError(
|
||||
f"Sonilo 请求超时 ({timeout_seconds:.0f}s)"
|
||||
) from exc
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise SoniloError(f"Sonilo 请求失败: {str(exc)}") from exc
|
||||
|
||||
|
||||
def _consume_ndjson_stream(lines: Iterable[str]) -> bytes:
|
||||
"""
|
||||
消费 NDJSON 事件流,按 stream_index 分组 base64 音频分片,
|
||||
返回第一条音轨。
|
||||
"""
|
||||
streams = {}
|
||||
completed = False
|
||||
for line in lines:
|
||||
if not line or not line.strip():
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
event_type = event.get("type")
|
||||
if event_type == "audio_chunk":
|
||||
chunk = event.get("data")
|
||||
if not isinstance(chunk, str):
|
||||
continue
|
||||
try:
|
||||
index = int(event.get("stream_index", 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if index < 0:
|
||||
continue
|
||||
try:
|
||||
decoded = base64.b64decode(chunk, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
continue
|
||||
streams.setdefault(index, bytearray()).extend(decoded)
|
||||
elif event_type == "complete":
|
||||
completed = True
|
||||
elif event_type == "error":
|
||||
message = event.get("message") or event.get("code") or "stream error"
|
||||
raise SoniloError(f"Sonilo 生成失败: {message}")
|
||||
# title / stage_start 等进度事件一律忽略。
|
||||
|
||||
if not completed:
|
||||
raise SoniloError("Sonilo 事件流意外终止(未收到 complete 事件)")
|
||||
if not streams:
|
||||
raise SoniloError("Sonilo 事件流已完成但未返回音频数据")
|
||||
first_index = sorted(streams)[0]
|
||||
return bytes(streams[first_index])
|
||||
@ -19,6 +19,7 @@ from app.services import (
|
||||
update_script,
|
||||
generate_video,
|
||||
script_subtitle,
|
||||
sonilo,
|
||||
)
|
||||
from app.services import state as sm
|
||||
from app.utils import utils
|
||||
@ -214,6 +215,27 @@ def _build_subtitle_mask_options(params: VideoClipParams, enabled=None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _resolve_bgm_path(task_id: str, params: VideoClipParams, combined_video_path: str) -> str:
|
||||
"""解析最终合成使用的背景音乐文件路径。
|
||||
|
||||
bgm_type 为 "sonilo" 时(可选功能,默认关闭),将合并后的成片上传到
|
||||
Sonilo API 生成配乐;任何失败都只记录日志并回退到现有的随机背景音乐
|
||||
逻辑,绝不中断成片任务。其余 bgm_type 走原有逻辑,保持不变。
|
||||
"""
|
||||
if getattr(params, "bgm_type", "") == "sonilo":
|
||||
save_path = path.join(utils.task_dir(task_id), "sonilo_bgm.m4a")
|
||||
bgm_path = sonilo.generate_bgm(combined_video_path, save_path)
|
||||
if bgm_path:
|
||||
return bgm_path
|
||||
logger.warning("Sonilo 配乐不可用,回退到随机背景音乐")
|
||||
return utils.get_bgm_file(bgm_type="random", bgm_file="")
|
||||
|
||||
return utils.get_bgm_file(
|
||||
bgm_type=getattr(params, "bgm_type", "random"),
|
||||
bgm_file=getattr(params, "bgm_file", ""),
|
||||
)
|
||||
|
||||
|
||||
def _transcribe_final_video(task_id: str, video_path: str, params: VideoClipParams) -> str:
|
||||
"""Transcribe the fully merged video into an SRT file."""
|
||||
from app.services import fun_asr_subtitle
|
||||
@ -521,10 +543,7 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
|
||||
logger.info(f"\n\n## 6. 最后一步: 合并字幕/BGM/配音/视频 -> {merge_output_video_path}")
|
||||
|
||||
# bgm_path = '/Users/apple/Desktop/home/NarratoAI/resource/songs/bgm.mp3'
|
||||
bgm_path = utils.get_bgm_file(
|
||||
bgm_type=getattr(params, "bgm_type", "random"),
|
||||
bgm_file=getattr(params, "bgm_file", ""),
|
||||
)
|
||||
bgm_path = _resolve_bgm_path(task_id, params, combined_video_path)
|
||||
|
||||
# 获取优化的音量配置
|
||||
optimized_volumes = get_recommended_volumes_for_content('mixed')
|
||||
@ -850,10 +869,7 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
|
||||
ffmpeg_progress=0,
|
||||
)
|
||||
|
||||
bgm_path = utils.get_bgm_file(
|
||||
bgm_type=getattr(params, "bgm_type", "random"),
|
||||
bgm_file=getattr(params, "bgm_file", ""),
|
||||
)
|
||||
bgm_path = _resolve_bgm_path(task_id, params, combined_video_path)
|
||||
|
||||
# 获取优化的音量配置
|
||||
optimized_volumes = get_recommended_volumes_for_content('mixed')
|
||||
|
||||
@ -67,6 +67,15 @@
|
||||
tavily_search_depth = "basic" # basic / advanced / fast / ultra-fast
|
||||
tavily_max_results = 5
|
||||
|
||||
# ===== 可选:Sonilo AI 配乐(BGM)=====
|
||||
# 在 WebUI 背景音乐来源中选择 "AI 生成配乐(Sonilo)" 即可启用(默认关闭,不影响现有 BGM 逻辑)。
|
||||
# 启用后会将合成完成的视频(未加 BGM)上传到 Sonilo API,根据画面内容与剪辑节奏生成配乐;
|
||||
# 生成的音乐已获授权、可商用(以条款为准)。视频时长上限 6 分钟,生成失败时自动回退到随机背景音乐。
|
||||
sonilo_api_key = "" # 获取地址:https://sonilo.com
|
||||
# sonilo_base_url = "https://api.sonilo.com"
|
||||
# sonilo_timeout_seconds = 600 # 生成接口读超时(秒)
|
||||
# sonilo_bgm_prompt = "" # 可选:配乐风格提示,留空则完全根据画面生成
|
||||
|
||||
# ===== API Keys 参考 =====
|
||||
# 主流 LLM Providers API Key 获取地址:
|
||||
#
|
||||
|
||||
163
tests/test_sonilo_bgm_unittest.py
Normal file
163
tests/test_sonilo_bgm_unittest.py
Normal file
@ -0,0 +1,163 @@
|
||||
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()
|
||||
@ -3,8 +3,9 @@ import os
|
||||
import shutil
|
||||
import json
|
||||
from uuid import uuid4
|
||||
from loguru import logger
|
||||
from app.config import config
|
||||
from app.services import voice
|
||||
from app.services import sonilo, voice
|
||||
from app.models.schema import AudioVolumeDefaults
|
||||
from app.utils import utils
|
||||
|
||||
@ -1937,16 +1938,58 @@ def render_voice_preview(tr, voice_name):
|
||||
st.error(tr("Voice synthesis failed"))
|
||||
|
||||
|
||||
def render_sonilo_bgm_settings(tr):
|
||||
"""渲染 Sonilo AI 配乐设置(可选功能,默认关闭)"""
|
||||
# 避免在本模块顶层引入 basic_settings 的重依赖链,按需导入。
|
||||
from webui.components.basic_settings import update_app_config_if_changed
|
||||
|
||||
st.info(tr("Sonilo BGM Notice"))
|
||||
|
||||
sonilo_api_key = st.text_input(
|
||||
tr("Sonilo API Key"),
|
||||
value=config.app.get("sonilo_api_key", ""),
|
||||
type="password",
|
||||
help=tr("Sonilo API Key Help"),
|
||||
key="sonilo_api_key_input",
|
||||
)
|
||||
sonilo_bgm_prompt = st.text_input(
|
||||
tr("Sonilo BGM Prompt"),
|
||||
value=config.app.get("sonilo_bgm_prompt", ""),
|
||||
help=tr("Sonilo BGM Prompt Help"),
|
||||
key="sonilo_bgm_prompt_input",
|
||||
)
|
||||
|
||||
api_key_changed = update_app_config_if_changed(
|
||||
"sonilo_api_key", str(sonilo_api_key or "").strip()
|
||||
)
|
||||
prompt_changed = update_app_config_if_changed(
|
||||
"sonilo_bgm_prompt", str(sonilo_bgm_prompt or "").strip()
|
||||
)
|
||||
if api_key_changed or prompt_changed:
|
||||
try:
|
||||
config.save_config()
|
||||
st.success(tr("Sonilo config saved"))
|
||||
except Exception as e:
|
||||
st.error(f"{tr('Failed to save config')}: {str(e)}")
|
||||
logger.error(f"保存 Sonilo 配置失败: {str(e)}")
|
||||
|
||||
if not sonilo.is_enabled():
|
||||
st.warning(tr("Sonilo API Key Required"))
|
||||
|
||||
|
||||
def render_bgm_settings(tr):
|
||||
"""渲染背景音乐设置"""
|
||||
saved_bgm_file = st.session_state.get('bgm_file', '')
|
||||
saved_bgm_source = st.session_state.get('bgm_source', 'resource')
|
||||
if st.session_state.get('bgm_type') == "":
|
||||
saved_bgm_source = "none"
|
||||
elif st.session_state.get('bgm_type') == "sonilo":
|
||||
saved_bgm_source = "sonilo"
|
||||
|
||||
bgm_source_labels = {
|
||||
"resource": "Select from Resource Directory",
|
||||
"upload": "Upload Background Music",
|
||||
"sonilo": "Sonilo AI Background Music",
|
||||
"none": "No Background Music",
|
||||
}
|
||||
if saved_bgm_source not in bgm_source_labels:
|
||||
@ -2029,12 +2072,18 @@ def render_bgm_settings(tr):
|
||||
tr,
|
||||
)
|
||||
|
||||
if bgm_source == "sonilo":
|
||||
render_sonilo_bgm_settings(tr)
|
||||
|
||||
preview_bgm_path = st.session_state.get("bgm_preview_path", "")
|
||||
if preview_bgm_path == bgm_file and os.path.isfile(preview_bgm_path):
|
||||
with open(preview_bgm_path, "rb") as audio_file:
|
||||
st.audio(audio_file.read(), format=get_audio_mime_type(preview_bgm_path))
|
||||
|
||||
bgm_type = "" if bgm_source == "none" or not bgm_file else "custom"
|
||||
if bgm_source == "sonilo":
|
||||
bgm_type = "sonilo"
|
||||
else:
|
||||
bgm_type = "" if bgm_source == "none" or not bgm_file else "custom"
|
||||
st.session_state['bgm_source'] = bgm_source
|
||||
st.session_state['bgm_type'] = bgm_type
|
||||
st.session_state['bgm_file'] = bgm_file if bgm_type else ""
|
||||
|
||||
@ -54,7 +54,15 @@
|
||||
"Custom Background Music": "Custom Background Music",
|
||||
"Custom Background Music File": "Please enter the file path of the custom background music",
|
||||
"Background Music Source": "Background Music Source",
|
||||
"Background Music Source Help": "Choose background music from the resource directory, upload a new file, or disable background music.",
|
||||
"Background Music Source Help": "Choose background music from the resource directory, upload a new file, let Sonilo generate music from the visuals, or disable background music.",
|
||||
"Sonilo AI Background Music": "AI-Generated Background Music (Sonilo)",
|
||||
"Sonilo BGM Notice": "When enabled, the assembled video (without background music) is uploaded to the Sonilo API to generate background music that follows the visuals and editing pace; the narration keeps its existing volume settings and stays clearly audible. Generated music is licensed for commercial use (terms apply). Videos longer than 6 minutes are not supported; if generation fails, the task falls back to random background music.",
|
||||
"Sonilo API Key": "Sonilo API Key",
|
||||
"Sonilo API Key Help": "Get an API key at https://sonilo.com. Only used when Sonilo background music is selected.",
|
||||
"Sonilo API Key Required": "Please enter a Sonilo API Key first, otherwise the task will fall back to random background music.",
|
||||
"Sonilo BGM Prompt": "Music Style Hint (Optional)",
|
||||
"Sonilo BGM Prompt Help": "Optional: describe the desired music style, e.g. \"calm piano\" or \"tense suspense\". Leave empty to generate purely from the visuals.",
|
||||
"Sonilo config saved": "Sonilo configuration saved",
|
||||
"Upload Background Music": "Upload Background Music",
|
||||
"Background Music Path Help": "Choose the background music used for video synthesis.",
|
||||
"No Background Music Resources Found": "No background music resources found. Please upload a background music file.",
|
||||
|
||||
@ -42,7 +42,15 @@
|
||||
"Custom Background Music": "自定义背景音乐",
|
||||
"Custom Background Music File": "请输入自定义背景音乐的文件路径",
|
||||
"Background Music Source": "背景音乐来源",
|
||||
"Background Music Source Help": "选择资源目录中的背景音乐、上传新的背景音乐,或关闭背景音乐",
|
||||
"Background Music Source Help": "选择资源目录中的背景音乐、上传新的背景音乐、使用 Sonilo 根据画面生成配乐,或关闭背景音乐",
|
||||
"Sonilo AI Background Music": "AI 生成配乐(Sonilo)",
|
||||
"Sonilo BGM Notice": "启用后,合成完成的视频(未加背景音乐)将上传至 Sonilo API,根据画面内容与剪辑节奏生成配乐;解说配音仍按现有音量设置保持清晰。生成的音乐已获授权、可商用(以条款为准)。视频时长上限 6 分钟;生成失败时自动回退到随机背景音乐,不影响成片。",
|
||||
"Sonilo API Key": "Sonilo API Key",
|
||||
"Sonilo API Key Help": "获取地址:https://sonilo.com,仅在选择 Sonilo 配乐时使用",
|
||||
"Sonilo API Key Required": "请先填写 Sonilo API Key,否则生成时将回退到随机背景音乐",
|
||||
"Sonilo BGM Prompt": "配乐风格提示(可选)",
|
||||
"Sonilo BGM Prompt Help": "可选:描述期望的配乐风格,例如“舒缓钢琴”“紧张悬疑”,留空则完全根据画面生成",
|
||||
"Sonilo config saved": "Sonilo 配置已保存",
|
||||
"Upload Background Music": "上传背景音乐",
|
||||
"Background Music Path Help": "选择用于视频合成的背景音乐",
|
||||
"No Background Music Resources Found": "未找到资源目录中的背景音乐,请上传背景音乐文件",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user