mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-07-17 11:38:09 +00:00
背景音乐来源新增 "AI 生成配乐(Sonilo)":将合成完成的视频(未加 BGM) 上传到 Sonilo API,根据画面内容与剪辑节奏生成配乐,音频文件交还给现有 合成流程混音,解说配音音量策略保持不变。 - 独立模块 app/services/sonilo.py,仅在配置 sonilo_api_key 后可用 - 上传前 ffprobe 本地校验时长(接口上限 6 分钟),避免白传计费 - 任何失败只记录日志并回退到随机背景音乐,绝不中断成片任务 - WebUI 选项处提示:视频将上传至 Sonilo API,生成的音乐已获授权、 可商用(以条款为准) - 现有 resource/upload/none 的 BGM 逻辑不做任何改动 Closes #269
274 lines
10 KiB
Python
274 lines
10 KiB
Python
"""
|
||
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])
|