ForcePilot/backend/package/yuxi/knowledge/parser/ffmpeg.py
Kris b3bdb4fa2f feat: 新增TTS语音合成、音视频解析能力
1. 新增内置TTS工具,支持OpenAI兼容语音合成服务
2. 新增ASR音频解析和视频解析处理器
3. 扩展文件解析支持范围,添加音视频格式支持
4. 新增ffmpeg音视频处理基础工具库
5. 补充相关单元测试用例
2026-06-13 20:33:40 +08:00

207 lines
7.0 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.

"""ffmpeg/ffprobe 执行基础设施,提供音视频处理的底层能力。"""
import json
import subprocess
import threading
from pathlib import Path
from yuxi.utils import logger
# ── 限制常量 ──────────────────────────────────────────────
FFMPEG_MAX_BUFFER_BYTES = 10 * 1024 * 1024 # 10MB
FFPROBE_TIMEOUT_MS = 10_000 # 10s
FFMPEG_TIMEOUT_MS = 45_000 # 45s
FFMPEG_MAX_AUDIO_DURATION_SECS = 1200 # 20 minutes
MAX_AUDIO_BYTES = 16 * 1024 * 1024 # 16MB
MAX_VIDEO_BYTES = 16 * 1024 * 1024 # 16MB
FFMPEG_MAX_CONCURRENT = 4
# ── 并发信号量 ────────────────────────────────────────────
_semaphore = threading.Semaphore(FFMPEG_MAX_CONCURRENT)
# ── 异常 ──────────────────────────────────────────────────
class FFmpegError(Exception):
"""ffmpeg/ffprobe 执行错误。"""
def __init__(self, message: str, returncode: int = -1):
super().__init__(message)
self.message = message
self.returncode = returncode
# ── 核心执行函数 ──────────────────────────────────────────
def run_ffmpeg(args: list[str], timeout_ms: int = FFMPEG_TIMEOUT_MS, *, return_stderr: bool = False) -> str:
"""同步执行 ffmpeg 命令,默认返回 stdout 内容return_stderr=True 时返回 stderr。"""
cmd = ["ffmpeg", *args]
timeout_sec = timeout_ms / 1000
with _semaphore:
try:
result = subprocess.run(
cmd,
capture_output=True,
timeout=timeout_sec,
)
except subprocess.TimeoutExpired:
raise FFmpegError(
f"ffmpeg 执行超时 ({timeout_ms}ms): {' '.join(cmd)}",
returncode=-1,
)
except OSError as e:
raise FFmpegError(
f"ffmpeg 执行失败(请确认 ffmpeg 已安装): {e}",
returncode=-1,
)
output = result.stderr if return_stderr else result.stdout
if len(output) > FFMPEG_MAX_BUFFER_BYTES:
raise FFmpegError(
f"ffmpeg 输出超过缓冲区限制 ({FFMPEG_MAX_BUFFER_BYTES} bytes)",
returncode=result.returncode,
)
if result.returncode != 0:
stderr = result.stderr.decode(errors="replace")[:512]
raise FFmpegError(
f"ffmpeg 返回非零退出码 {result.returncode}: {stderr}",
returncode=result.returncode,
)
return output.decode(errors="replace")
def run_ffprobe(args: list[str], timeout_ms: int = FFPROBE_TIMEOUT_MS) -> str:
"""同步执行 ffprobe 命令,返回 stdout 内容。"""
cmd = ["ffprobe", *args]
timeout_sec = timeout_ms / 1000
with _semaphore:
try:
result = subprocess.run(
cmd,
capture_output=True,
timeout=timeout_sec,
)
except subprocess.TimeoutExpired:
raise FFmpegError(
f"ffprobe 执行超时 ({timeout_ms}ms): {' '.join(cmd)}",
returncode=-1,
)
except OSError as e:
raise FFmpegError(
f"ffprobe 执行失败(请确认 ffprobe 已安装): {e}",
returncode=-1,
)
if len(result.stdout) > FFMPEG_MAX_BUFFER_BYTES:
raise FFmpegError(
f"ffprobe 输出超过缓冲区限制 ({FFMPEG_MAX_BUFFER_BYTES} bytes)",
returncode=result.returncode,
)
if result.returncode != 0:
stderr = result.stderr.decode(errors="replace")[:512]
raise FFmpegError(
f"ffprobe 返回非零退出码 {result.returncode}: {stderr}",
returncode=result.returncode,
)
return result.stdout.decode(errors="replace")
# ── 业务函数 ──────────────────────────────────────────────
def probe_audio_duration(file_path: str) -> float | None:
"""使用 ffprobe 获取音频时长(秒),失败返回 None。"""
try:
output = run_ffprobe(
["-v", "quiet", "-print_format", "json", "-show_format", file_path],
)
info = json.loads(output)
return float(info["format"]["duration"])
except (FFmpegError, KeyError, ValueError, json.JSONDecodeError) as e:
logger.warning("获取音频时长失败: %s%s", file_path, e)
return None
def convert_to_wav(input_path: str, output_path: str) -> None:
"""将音频转换为 WAV 格式16kHz, 单声道, PCM s16le"""
run_ffmpeg([
"-i", input_path,
"-vn",
"-acodec", "pcm_s16le",
"-ar", "16000",
"-ac", "1",
output_path,
])
def extract_audio_from_video(video_path: str, output_path: str) -> None:
"""从视频中提取音轨,输出为 WAV 格式16kHz, 单声道, PCM s16le"""
run_ffmpeg([
"-i", video_path,
"-vn",
"-acodec", "pcm_s16le",
"-ar", "16000",
"-ac", "1",
output_path,
])
def extract_keyframes(
video_path: str,
output_dir: str,
scene_threshold: float = 0.3,
) -> list[tuple[str, float]]:
"""通过场景变化检测提取关键帧,返回 [(路径, 时间戳秒), ...] 按时间排序。
分两步执行:
1. 用 ffmpeg select+showinfo 检测场景切换帧的 PTS
2. 用 -ss 精确跳转到每个时间点提取帧
"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
# 第一步:检测场景切换帧的 PTS 时间戳
timestamps = _detect_scene_change_pts(video_path, scene_threshold)
if not timestamps:
return []
# 第二步:在每个时间点提取帧
frames: list[tuple[str, float]] = []
for i, ts in enumerate(timestamps, start=1):
output_path = str(Path(output_dir) / f"frame_{i:04d}.png")
try:
run_ffmpeg([
"-ss", f"{ts:.6f}",
"-i", video_path,
"-frames:v", "1",
output_path,
])
frames.append((output_path, ts))
except FFmpegError:
logger.debug("关键帧提取跳过: ts=%.2f", ts)
return frames
def _detect_scene_change_pts(video_path: str, scene_threshold: float) -> list[float]:
"""使用 ffmpeg select+showinfo 检测场景切换帧的 PTS 时间戳。"""
import re
stderr_text = run_ffmpeg([
"-i", video_path,
"-vf", f"select=gt(scene\\,{scene_threshold}),showinfo",
"-vsync", "vfr",
"-f", "null", "-",
], return_stderr=True)
timestamps: list[float] = []
for match in re.finditer(r"pts_time:(\d+\.?\d*)", stderr_text):
try:
timestamps.append(float(match.group(1)))
except ValueError:
pass
return timestamps