NarratoAI/app/services/sonilo.py
Cindy Xu 14828256de feat(sfx): 新增 Sonilo AI 音效模式(可选,默认关闭)
#270 配乐模式基础上新增可选的 "AI 音效(Sonilo)":将合并后的成片
上传到 Sonilo API(POST /v1/video-to-sfx,异步任务管线),根据画面内容
生成音效,再用 ffmpeg 混在成片现有音轨之下(视频流直接复制,不重编码
画面)。解说配音在后续合成步骤中单独混入,音量策略不受影响。生成的
音效为免版税素材。

- 复用 #270 的 sonilo_api_key 配置与 app/services/sonilo.py 模块,
  新增音效任务管线(提交 / 轮询 / 预签名 URL 下载)
- 上传前 ffprobe 本地校验时长(音效接口上限 3 分钟),避免白传计费
- 轮询是免费幂等 GET:网络抖动与 5xx 在截止时间内重试,不报废已计费任务
- 任何失败(超时、HTTP 错误、任务失败、混音失败)只记录日志并沿用
  原视频,绝不中断成片任务
- WebUI 音频设置新增独立开关面板(默认关闭),选项处提示视频将上传至
  Sonilo API
- 现有 BGM / 合成逻辑不做任何改动,未勾选时行为与 main 完全一致
2026-07-22 13:17:14 -07:00

627 lines
23 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与 AI 音效SFX集成 ——
均为可选功能,默认关闭。
配乐BGM将合成完成的视频未加 BGM上传到 Sonilo API
`POST /v1/video-to-music`),根据画面内容与剪辑节奏生成一段背景音乐,
作为普通音频文件交还给现有的合成流程混音。生成的音乐已获授权、
可商用(以条款为准)。
音效SFX将合成完成的视频上传到 Sonilo API
`POST /v1/video-to-sfx`),根据画面内容生成贴合画面的音效。该接口是
异步任务:提交后返回 task_id轮询 `GET /v1/tasks/{task_id}` 直到终态,
成功后从预签名 URL 下载音效音频,再用 ffmpeg 混在成片现有音轨之下
(解说配音在后续合成步骤中单独混入,音量策略不受影响)。生成的音效为
免版税素材。
设计约束(完全不影响现有合成逻辑):
* 默认关闭。配乐仅当用户在 WebUI 中把背景音乐来源切换为 Sonilo 时启用;
音效仅当用户勾选 "AI 音效Sonilo" 时启用。两者都要求配置了
Sonilo API Keyconfig.toml 的 `sonilo_api_key`,或环境变量
`SONILO_API_KEY` 兜底)。
* 配乐模块只负责生成音频文件;音量、淡出、循环与混音全部复用
app/services/generate_video.py 中现有的音频处理逻辑,解说配音的
音量压制策略保持不变。
* 任何失败超时、HTTP 错误、流中断、时长超限、混音失败)都只记录
日志并返回空字符串,由调用方回退到现有逻辑,绝不中断成片任务。
* 上传属于计费操作,上传前先用 ffprobe 在本地校验视频时长(配乐接口
目前拒绝超过 6 分钟的视频,音效接口拒绝超过 3 分钟的视频),避免
白传一次注定被拒绝的成片。
配乐接口返回 NDJSON 事件流:`audio_chunk`base64 音频分片,按
stream_index 分组)、`title`、`complete`(成功终止事件)与 `error`
(失败终止事件)。进度事件与无法解析的行一律忽略。生成的音频为 AAC
编码的 .m4a 文件。
音效接口为异步任务管线:`POST /v1/video-to-sfx` 受理后即计费并返回
`{"task_id": ...}``GET /v1/tasks/{task_id}` 返回
`{"status": "succeeded"/"failed"/..., "audio": {"url": ...}, "error": ...,
"refunded": ...}`。结果地址是预签名 URL下载时绝不能携带 API Key。
配置config.toml 的 [app] 段):
sonilo_api_key = "..." # 必填,启用开关(配乐与音效共用)
# sonilo_base_url = "https://api.sonilo.com"
# sonilo_timeout_seconds = 600
# sonilo_bgm_prompt = "" # 可选:配乐风格提示
# sonilo_sfx_prompt = "" # 可选:音效风格提示
# sonilo_sfx_volume = 0.6 # 音效混入原声之下的音量0-2]
"""
import base64
import binascii
import json
import os
import subprocess
import time
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"
VIDEO_TO_SFX_PATH = "/v1/video-to-sfx"
TASKS_PATH = "/v1/tasks"
# 后端生成接口的读超时约为 600 秒。生成一旦开始就会计费,客户端过早超时
# 只会浪费一次已经付费的请求,所以默认读超时与后端保持一致,并允许覆盖。
DEFAULT_TIMEOUT_SECONDS = 600
_CONNECT_TIMEOUT_SECONDS = 15
# 轮询任务状态是免费且幂等的 GET单次请求用短读超时即可。
_POLL_READ_TIMEOUT_SECONDS = 30
_SFX_POLL_INTERVAL_SECONDS = 5.0
# 测试接缝:单测里替换为 no-op避免真实等待。
_sleep = time.sleep
# 配乐接口目前拒绝超过 6 分钟的视频;上传前先在本地校验时长。
MAX_VIDEO_DURATION_SECONDS = 360
# 音效接口目前拒绝超过 3 分钟的视频。
MAX_SFX_VIDEO_DURATION_SECONDS = 180
# 音效混入原声之下的默认音量(解说配音在后续合成步骤中以 1.0 混入,
# 音效保持在其之下)。可通过 sonilo_sfx_volume 配置覆盖。
DEFAULT_SFX_VOLUME = 0.6
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 _get_base_url() -> str:
return str(config.app.get("sonilo_base_url", "") or DEFAULT_BASE_URL).rstrip("/")
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 = _get_base_url()
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])
# ---------- AI 音效SFX可选功能默认关闭 ----------
def apply_sfx(video_path: str, output_path: str) -> str:
"""
为合成完成的视频生成 Sonilo 音效,并用 ffmpeg 混在现有音轨之下,
输出新视频到 `output_path`(视频流直接复制,不重编码画面)。
成功时返回输出视频路径,任何失败都返回空字符串,由调用方沿用
原视频。本函数绝不抛出异常 —— 音效问题绝不能中断成片任务。
"""
sfx_audio_path = os.path.splitext(output_path)[0] + ".m4a"
if not generate_sfx(video_path, sfx_audio_path):
return ""
return _mix_sfx_under_original(video_path, sfx_audio_path, output_path)
def generate_sfx(video_path: str, save_path: str) -> str:
"""
上传合成完成的视频到 Sonilo生成音效音频并保存到 `save_path`.m4a
成功时返回音频文件路径,任何失败都返回空字符串。与 generate_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_SFX_VIDEO_DURATION_SECONDS:
logger.warning(
f"Sonilo 音效已跳过: 视频时长 {duration:.1f}s 超过接口上限 "
f"{MAX_SFX_VIDEO_DURATION_SECONDS}s"
)
return ""
try:
audio = _request_video_to_sfx(video_path)
except Exception as e:
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 _request_video_to_sfx(video_path: str) -> bytes:
"""提交音效任务、轮询到终态、下载结果音频。失败抛出 SoniloError。"""
task_id = _submit_sfx_task(video_path)
body = _poll_sfx_task(task_id)
return _download_sfx_audio(_extract_sfx_audio_url(body, task_id))
def _submit_sfx_task(video_path: str) -> str:
"""POST /v1/video-to-sfx受理后返回 task_id受理即计费不做重试"""
timeout_seconds = _get_timeout_seconds()
prompt = str(config.app.get("sonilo_sfx_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}")
try:
with open(video_path, "rb") as video_file:
files = {
"video": (os.path.basename(video_path), video_file, "video/mp4"),
}
response = requests.post(
f"{_get_base_url()}{VIDEO_TO_SFX_PATH}",
headers=headers,
data=data,
files=files,
timeout=(_CONNECT_TIMEOUT_SECONDS, timeout_seconds),
)
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
if response.status_code >= 400:
body = response.content.decode("utf-8", errors="replace")
raise SoniloError(_http_error_message(response.status_code, body))
try:
task_id = response.json().get("task_id")
except (ValueError, AttributeError):
task_id = None
if not task_id:
raise SoniloError("Sonilo 音效任务已受理但未返回 task_id")
task_id = str(task_id)
# 受理即计费;先把 task_id 落进日志,后续轮询失败时仍有据可查。
logger.info(f"Sonilo 音效任务已提交: {task_id}")
return task_id
def _poll_sfx_task(task_id: str) -> dict:
"""
轮询 GET /v1/tasks/{task_id} 直到任务终态succeeded/failed或超时。
succeeded 时返回任务体failed / 超时 / 不可恢复的 HTTP 错误抛出
SoniloError。轮询是免费且幂等的 GET网络抖动与 5xx 不该报废一次
已计费的任务,在截止时间内继续重试。
"""
headers = {"Authorization": f"Bearer {get_api_key()}"}
timeout_seconds = _get_timeout_seconds()
deadline = time.monotonic() + timeout_seconds
while True:
response = None
try:
response = requests.get(
f"{_get_base_url()}{TASKS_PATH}/{task_id}",
headers=headers,
timeout=(_CONNECT_TIMEOUT_SECONDS, _POLL_READ_TIMEOUT_SECONDS),
)
except requests.exceptions.RequestException as exc:
logger.warning(f"Sonilo 音效任务查询失败(将重试): {str(exc)}")
if response is not None:
if response.status_code >= 500:
logger.warning(
f"Sonilo 音效任务查询返回 {response.status_code}(将重试)"
)
elif response.status_code >= 400:
body = response.content.decode("utf-8", errors="replace")
raise SoniloError(
f"{_http_error_message(response.status_code, body)}"
f"(任务已提交, task_id: {task_id}"
)
else:
try:
body = response.json()
except ValueError:
body = None
if isinstance(body, dict):
status = body.get("status")
if status == "succeeded":
return body
if status == "failed":
raise SoniloError(_task_failure_message(body, task_id))
# 非终态pending / processing 等)继续等待。
if time.monotonic() >= deadline:
raise SoniloError(
f"等待 Sonilo 音效任务超时 ({timeout_seconds:.0f}s), "
f"task_id: {task_id}"
)
_sleep(_SFX_POLL_INTERVAL_SECONDS)
def _task_failure_message(body: dict, task_id: str) -> str:
err = body.get("error")
if isinstance(err, dict):
message = err.get("message") or err.get("code") or "生成失败"
elif isinstance(err, str) and err:
message = err
else:
message = "生成失败"
refund_note = ",费用已退还" if body.get("refunded") is True else ""
return f"Sonilo 音效生成失败: {message}task_id: {task_id}{refund_note}"
def _extract_sfx_audio_url(body: dict, task_id: str) -> str:
audio = body.get("audio")
if isinstance(audio, dict):
url = audio.get("url")
if isinstance(url, str) and url:
return url
raise SoniloError(
f"Sonilo 音效任务成功但未返回音频结果 (task_id: {task_id})"
)
def _download_sfx_audio(url: str) -> bytes:
"""下载任务结果音频。结果地址是预签名 URL自带鉴权 ——
绝不能把 API Key 发给存储域名,因此这里不带任何鉴权头。"""
try:
response = requests.get(
url, timeout=(_CONNECT_TIMEOUT_SECONDS, _get_timeout_seconds())
)
except requests.exceptions.RequestException as exc:
raise SoniloError(f"Sonilo 音效结果下载失败: {str(exc)}") from exc
if response.status_code >= 400:
raise SoniloError(
f"Sonilo 音效结果下载失败 (HTTP {response.status_code})"
)
if not response.content:
raise SoniloError("Sonilo 音效结果为空")
return response.content
def _get_sfx_volume() -> float:
"""音效混入原声之下的音量。非法值或 <=0 回退默认值,上限 2.0。"""
try:
volume = float(config.app.get("sonilo_sfx_volume", DEFAULT_SFX_VOLUME))
except (TypeError, ValueError):
return DEFAULT_SFX_VOLUME
if volume <= 0:
return DEFAULT_SFX_VOLUME
return min(volume, 2.0)
def _get_ffmpeg_binary() -> str:
"""与 generate_video 保持一致的 ffmpeg 查找逻辑(环境变量优先)。"""
for env_name in ("NARRATO_FFMPEG_EXE", "IMAGEIO_FFMPEG_EXE"):
candidate = os.environ.get(env_name, "").strip()
if candidate and os.path.isfile(candidate):
return candidate
try:
import imageio_ffmpeg
candidate = imageio_ffmpeg.get_ffmpeg_exe()
if candidate and os.path.isfile(candidate):
return candidate
except Exception:
pass
return "ffmpeg"
def _probe_has_audio_stream(video_path: str) -> bool:
"""尽力而为地探测视频是否带音轨。探测失败按无音轨处理。"""
try:
result = subprocess.run(
[
_get_ffprobe_binary(),
"-v",
"quiet",
"-print_format",
"json",
"-show_streams",
"-select_streams",
"a",
video_path,
],
capture_output=True,
timeout=30,
)
except (OSError, subprocess.TimeoutExpired):
return False
if result.returncode != 0:
return False
try:
return bool(json.loads(result.stdout).get("streams"))
except (json.JSONDecodeError, AttributeError, TypeError, ValueError):
return False
def _mix_sfx_under_original(
video_path: str, sfx_audio_path: str, output_path: str
) -> str:
"""
用 ffmpeg 把音效混在成片现有音轨之下(音效音量默认 0.6,原声音量
不变),视频流直接复制不重编码。成片没有音轨时,音效直接作为音轨
写入。成功返回 output_path任何失败返回空字符串。
"""
volume = _get_sfx_volume()
has_audio = _probe_has_audio_stream(video_path)
if has_audio:
filter_complex = (
f"[1:a]volume={volume}[sfx];"
"[0:a][sfx]amix=inputs=2:duration=first:"
"dropout_transition=0:normalize=0[aout]"
)
else:
filter_complex = f"[1:a]volume={volume}[aout]"
cmd = [
_get_ffmpeg_binary(),
"-y",
"-i",
video_path,
"-i",
sfx_audio_path,
"-filter_complex",
filter_complex,
"-map",
"0:v",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
]
if not has_audio:
cmd.append("-shortest")
cmd.append(output_path)
logger.info(f"正在混入 Sonilo 音效 (音量 {volume}): {output_path}")
try:
result = subprocess.run(cmd, capture_output=True, timeout=300)
except (OSError, subprocess.TimeoutExpired) as e:
logger.error(f"Sonilo 音效混音失败: {str(e)}")
return ""
if result.returncode != 0:
stderr_tail = (result.stderr or b"").decode("utf-8", errors="replace")[-500:]
logger.error(f"Sonilo 音效混音失败 (ffmpeg 退出码 {result.returncode}): {stderr_tail}")
return ""
logger.success(f"Sonilo 音效已混入: {output_path}")
return output_path