Merge pull request #270 from cindyxu1030/feat/sonilo-bgm

feat(bgm): 新增 Sonilo AI 配乐模式(可选,默认关闭)
This commit is contained in:
viccy 2026-07-16 10:26:42 +08:00 committed by GitHub
commit d0216fcb04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 539 additions and 12 deletions

1
.gitignore vendored
View File

@ -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
View File

@ -0,0 +1,273 @@
"""
Sonilo (https://sonilo.com) AI 配乐BGM集成 可选功能默认关闭
将合成完成的视频未加 BGM上传到 Sonilo API`POST /v1/video-to-music`
根据画面内容与剪辑节奏生成一段背景音乐作为普通音频文件交还给现有的
合成流程混音生成的音乐已获授权可商用以条款为准
设计约束完全不影响现有 BGM 逻辑
* 默认关闭仅当用户在 WebUI 中把背景音乐来源切换为 Sonilo且配置了
Sonilo API Keyconfig.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])

View File

@ -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')

View File

@ -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 获取地址:
#

View 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()

View File

@ -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 ""

View File

@ -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.",

View 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": "未找到资源目录中的背景音乐,请上传背景音乐文件",