mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-07-29 09:25:52 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
445feb14ae
1
.gitignore
vendored
1
.gitignore
vendored
@ -54,6 +54,7 @@ tests/*
|
|||||||
!tests/test_generate_narration_script_documentary_unittest.py
|
!tests/test_generate_narration_script_documentary_unittest.py
|
||||||
!tests/test_generate_script_docu_unittest.py
|
!tests/test_generate_script_docu_unittest.py
|
||||||
!tests/test_streamlit_widget_session_state.py
|
!tests/test_streamlit_widget_session_state.py
|
||||||
|
!tests/test_sonilo_bgm_unittest.py
|
||||||
|
|
||||||
docs/reddit-community
|
docs/reddit-community
|
||||||
docs/wechat-0.8
|
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,
|
update_script,
|
||||||
generate_video,
|
generate_video,
|
||||||
script_subtitle,
|
script_subtitle,
|
||||||
|
sonilo,
|
||||||
)
|
)
|
||||||
from app.services import state as sm
|
from app.services import state as sm
|
||||||
from app.utils import utils
|
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:
|
def _transcribe_final_video(task_id: str, video_path: str, params: VideoClipParams) -> str:
|
||||||
"""Transcribe the fully merged video into an SRT file."""
|
"""Transcribe the fully merged video into an SRT file."""
|
||||||
from app.services import fun_asr_subtitle
|
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}")
|
logger.info(f"\n\n## 6. 最后一步: 合并字幕/BGM/配音/视频 -> {merge_output_video_path}")
|
||||||
|
|
||||||
# bgm_path = '/Users/apple/Desktop/home/NarratoAI/resource/songs/bgm.mp3'
|
# bgm_path = '/Users/apple/Desktop/home/NarratoAI/resource/songs/bgm.mp3'
|
||||||
bgm_path = utils.get_bgm_file(
|
bgm_path = _resolve_bgm_path(task_id, params, combined_video_path)
|
||||||
bgm_type=getattr(params, "bgm_type", "random"),
|
|
||||||
bgm_file=getattr(params, "bgm_file", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取优化的音量配置
|
# 获取优化的音量配置
|
||||||
optimized_volumes = get_recommended_volumes_for_content('mixed')
|
optimized_volumes = get_recommended_volumes_for_content('mixed')
|
||||||
@ -850,10 +869,7 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
|
|||||||
ffmpeg_progress=0,
|
ffmpeg_progress=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
bgm_path = utils.get_bgm_file(
|
bgm_path = _resolve_bgm_path(task_id, params, combined_video_path)
|
||||||
bgm_type=getattr(params, "bgm_type", "random"),
|
|
||||||
bgm_file=getattr(params, "bgm_file", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取优化的音量配置
|
# 获取优化的音量配置
|
||||||
optimized_volumes = get_recommended_volumes_for_content('mixed')
|
optimized_volumes = get_recommended_volumes_for_content('mixed')
|
||||||
|
|||||||
@ -67,6 +67,15 @@
|
|||||||
tavily_search_depth = "basic" # basic / advanced / fast / ultra-fast
|
tavily_search_depth = "basic" # basic / advanced / fast / ultra-fast
|
||||||
tavily_max_results = 5
|
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 参考 =====
|
# ===== API Keys 参考 =====
|
||||||
# 主流 LLM Providers API Key 获取地址:
|
# 主流 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 shutil
|
||||||
import json
|
import json
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from loguru import logger
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.services import voice
|
from app.services import sonilo, voice
|
||||||
from app.models.schema import AudioVolumeDefaults
|
from app.models.schema import AudioVolumeDefaults
|
||||||
from app.utils import utils
|
from app.utils import utils
|
||||||
|
|
||||||
@ -2032,16 +2033,58 @@ def render_voice_preview(tr, voice_name):
|
|||||||
st.error(tr("Voice synthesis failed"))
|
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):
|
def render_bgm_settings(tr):
|
||||||
"""渲染背景音乐设置"""
|
"""渲染背景音乐设置"""
|
||||||
saved_bgm_file = st.session_state.get('bgm_file', '')
|
saved_bgm_file = st.session_state.get('bgm_file', '')
|
||||||
saved_bgm_source = st.session_state.get('bgm_source', 'resource')
|
saved_bgm_source = st.session_state.get('bgm_source', 'resource')
|
||||||
if st.session_state.get('bgm_type') == "":
|
if st.session_state.get('bgm_type') == "":
|
||||||
saved_bgm_source = "none"
|
saved_bgm_source = "none"
|
||||||
|
elif st.session_state.get('bgm_type') == "sonilo":
|
||||||
|
saved_bgm_source = "sonilo"
|
||||||
|
|
||||||
bgm_source_labels = {
|
bgm_source_labels = {
|
||||||
"resource": "Select from Resource Directory",
|
"resource": "Select from Resource Directory",
|
||||||
"upload": "Upload Background Music",
|
"upload": "Upload Background Music",
|
||||||
|
"sonilo": "Sonilo AI Background Music",
|
||||||
"none": "No Background Music",
|
"none": "No Background Music",
|
||||||
}
|
}
|
||||||
if saved_bgm_source not in bgm_source_labels:
|
if saved_bgm_source not in bgm_source_labels:
|
||||||
@ -2124,12 +2167,18 @@ def render_bgm_settings(tr):
|
|||||||
tr,
|
tr,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if bgm_source == "sonilo":
|
||||||
|
render_sonilo_bgm_settings(tr)
|
||||||
|
|
||||||
preview_bgm_path = st.session_state.get("bgm_preview_path", "")
|
preview_bgm_path = st.session_state.get("bgm_preview_path", "")
|
||||||
if preview_bgm_path == bgm_file and os.path.isfile(preview_bgm_path):
|
if preview_bgm_path == bgm_file and os.path.isfile(preview_bgm_path):
|
||||||
with open(preview_bgm_path, "rb") as audio_file:
|
with open(preview_bgm_path, "rb") as audio_file:
|
||||||
st.audio(audio_file.read(), format=get_audio_mime_type(preview_bgm_path))
|
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_source'] = bgm_source
|
||||||
st.session_state['bgm_type'] = bgm_type
|
st.session_state['bgm_type'] = bgm_type
|
||||||
st.session_state['bgm_file'] = bgm_file if bgm_type else ""
|
st.session_state['bgm_file'] = bgm_file if bgm_type else ""
|
||||||
|
|||||||
@ -54,7 +54,15 @@
|
|||||||
"Custom Background Music": "Custom Background Music",
|
"Custom Background Music": "Custom Background Music",
|
||||||
"Custom Background Music File": "Please enter the file path of the 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": "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",
|
"Upload Background Music": "Upload Background Music",
|
||||||
"Background Music Path Help": "Choose the background music used for video synthesis.",
|
"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.",
|
"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": "自定义背景音乐",
|
||||||
"Custom Background Music File": "请输入自定义背景音乐的文件路径",
|
"Custom Background Music File": "请输入自定义背景音乐的文件路径",
|
||||||
"Background Music Source": "背景音乐来源",
|
"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": "上传背景音乐",
|
"Upload Background Music": "上传背景音乐",
|
||||||
"Background Music Path Help": "选择用于视频合成的背景音乐",
|
"Background Music Path Help": "选择用于视频合成的背景音乐",
|
||||||
"No Background Music Resources Found": "未找到资源目录中的背景音乐,请上传背景音乐文件",
|
"No Background Music Resources Found": "未找到资源目录中的背景音乐,请上传背景音乐文件",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user