NarratoAI/app/services/sonilo.py
Cindy Xu d29874a163 feat(bgm): 新增 Sonilo AI 配乐模式(可选,默认关闭)
背景音乐来源新增 "AI 生成配乐(Sonilo)":将合成完成的视频(未加 BGM)
上传到 Sonilo API,根据画面内容与剪辑节奏生成配乐,音频文件交还给现有
合成流程混音,解说配音音量策略保持不变。

- 独立模块 app/services/sonilo.py,仅在配置 sonilo_api_key 后可用
- 上传前 ffprobe 本地校验时长(接口上限 6 分钟),避免白传计费
- 任何失败只记录日志并回退到随机背景音乐,绝不中断成片任务
- WebUI 选项处提示:视频将上传至 Sonilo API,生成的音乐已获授权、
  可商用(以条款为准)
- 现有 resource/upload/none 的 BGM 逻辑不做任何改动

Closes #269
2026-07-13 21:00:03 -07:00

274 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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])