diff --git a/.gitignore b/.gitignore index 4cbb810..97a8cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/services/sonilo.py b/app/services/sonilo.py new file mode 100644 index 0000000..5b6e1fe --- /dev/null +++ b/app/services/sonilo.py @@ -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]) diff --git a/app/services/task.py b/app/services/task.py index b23a5b9..ea5cdb3 100644 --- a/app/services/task.py +++ b/app/services/task.py @@ -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') diff --git a/config.example.toml b/config.example.toml index b56e5b4..d0c5aa0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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 获取地址: # diff --git a/tests/test_sonilo_bgm_unittest.py b/tests/test_sonilo_bgm_unittest.py new file mode 100644 index 0000000..92c80f0 --- /dev/null +++ b/tests/test_sonilo_bgm_unittest.py @@ -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() diff --git a/webui/components/audio_settings.py b/webui/components/audio_settings.py index 1fda62d..320b09c 100644 --- a/webui/components/audio_settings.py +++ b/webui/components/audio_settings.py @@ -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 @@ -2032,16 +2033,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: @@ -2124,12 +2167,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 "" diff --git a/webui/i18n/en.json b/webui/i18n/en.json index a7aede1..6d3072b 100644 --- a/webui/i18n/en.json +++ b/webui/i18n/en.json @@ -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.", diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index 7bb53fb..feee817 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -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": "未找到资源目录中的背景音乐,请上传背景音乐文件",