mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-08-01 10:55:54 +00:00
在 #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 完全一致
627 lines
23 KiB
Python
627 lines
23 KiB
Python
"""
|
||
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 Key(config.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
|