1. 新增内置TTS工具,支持OpenAI兼容语音合成服务 2. 新增ASR音频解析和视频解析处理器 3. 扩展文件解析支持范围,添加音视频格式支持 4. 新增ffmpeg音视频处理基础工具库 5. 补充相关单元测试用例
207 lines
7.0 KiB
Python
207 lines
7.0 KiB
Python
"""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
|