Files
mxivideo/python_core/services/ffmpeg_slice_service.py
2025-07-12 14:38:15 +08:00

706 lines
26 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视频切片服务
基于demo.py的VideoUtils.ffmpeg_slice_media方法封装的专业视频切片服务。
"""
import asyncio
import os
import json
import math
from typing import Dict, List, Any, Optional, Tuple
from pathlib import Path
from datetime import timedelta
from dataclasses import dataclass
from ffmpeg.asyncio import FFmpeg as AsyncFFmpeg
from loguru import logger
@dataclass
class SliceSegment:
"""切片段配置"""
start: float # 开始时间(秒)
end: float # 结束时间(秒)
@property
def duration(self) -> float:
"""片段时长"""
return self.end - self.start
def to_timedelta(self) -> Tuple[timedelta, timedelta]:
"""转换为timedelta格式"""
return (
timedelta(seconds=self.start),
timedelta(seconds=self.end)
)
@dataclass
class SliceOptions:
"""切片输出选项"""
width: Optional[int] = None # 输出宽度
height: Optional[int] = None # 输出高度
crf: int = 23 # 视频质量 (18-28, 越小质量越好)
fps: int = 30 # 输出帧率
bit_rate: Optional[str] = None # 比特率 (如 "2M")
limit_size: Optional[str] = None # 文件大小限制 (如 "10M")
preset: str = "medium" # 编码预设 (ultrafast, fast, medium, slow, veryslow)
@property
def pretty_bit_rate(self) -> str:
"""格式化的比特率"""
return self.bit_rate or "2M"
@dataclass
class VideoMetadata:
"""视频元数据"""
duration: float
width: int
height: int
fps: float
format_name: str
size: int
codec_name: str
audio_codec: str
@classmethod
def from_ffprobe(cls, metadata: dict) -> 'VideoMetadata':
"""从ffprobe结果创建元数据"""
format_info = metadata.get('format', {})
video_stream = None
audio_stream = None
for stream in metadata.get('streams', []):
if stream.get('codec_type') == 'video':
video_stream = stream
elif stream.get('codec_type') == 'audio':
audio_stream = stream
if not video_stream:
raise ValueError("No video stream found")
# 解析帧率
r_frame_rate = video_stream.get('r_frame_rate', '30/1')
if '/' in r_frame_rate:
num, den = map(int, r_frame_rate.split('/'))
fps = num / den if den != 0 else 30.0
else:
fps = float(r_frame_rate)
# 获取音频编码器信息
audio_codec = audio_stream.get('codec_name', '') if audio_stream else ''
return cls(
duration=float(format_info.get('duration', 0)),
width=int(video_stream.get('width', 0)),
height=int(video_stream.get('height', 0)),
fps=fps,
format_name=format_info.get('format_name', 'unknown'),
size=int(format_info.get('size', 0)),
codec_name=video_stream.get('codec_name', 'unknown'),
audio_codec=audio_codec
)
class FfmpegSliceService:
"""
FFmpeg视频切片服务
提供专业的视频切片功能,支持按时间段切割、质量控制、批量处理等。
"""
def __init__(self):
self.logger = logger
def _create_async_ffmpeg_cmd(self, quiet: bool = False) -> Optional[Any]:
"""创建异步FFmpeg命令对象"""
# 使用隐藏窗口的FFmpeg包装器
ffmpeg_cmd = AsyncFFmpeg(executable="ffmpeg").option('y').option('hide_banner')
@ffmpeg_cmd.on("start")
def on_start(arguments: list[str]):
try:
filter_index = arguments.index("-filter_complex")
filter_content = arguments[filter_index + 1]
arguments[filter_index + 1] = f'"{filter_content}"'
args = " ".join(arguments)
arguments[filter_index + 1] = filter_content
except ValueError:
args = " ".join(arguments)
logger.info(f"FFmpeg command: {args}")
@ffmpeg_cmd.on("progress")
def on_progress(progress):
if not quiet:
logger.info(f"处理进度: {progress}")
@ffmpeg_cmd.on("completed")
def on_completed(result=None):
logger.info(f"FFmpeg task completed.")
@ffmpeg_cmd.on("stderr")
def on_stderr(line: str):
if line.startswith('Error') and ".m3u8" not in line:
raise RuntimeError(line)
elif "Output file is empty" in line:
raise RuntimeError("输出是空文件")
else:
...
return ffmpeg_cmd
async def get_video_metadata(self, media_path: str) -> VideoMetadata:
"""
获取视频元数据
Args:
media_path: 视频文件路径
Returns:
VideoMetadata: 视频元数据对象
"""
ffprobe = AsyncFFmpeg(executable='ffprobe')
# 配置FFprobe参数
ffprobe.option("v", "quiet")
ffprobe.option("print_format", "json")
ffprobe.option("show_streams", None) # 明确指定None值
ffprobe.option("show_format", None) # 明确指定None值
# 首先验证文件是否存在和有效
media_file = Path(media_path)
if not media_file.exists():
raise FileNotFoundError(f"视频文件不存在: {media_path}")
if media_file.stat().st_size == 0:
raise RuntimeError(f"视频文件为空: {media_path}")
ffprobe.input(media_path)
logger.info(f"开始获取视频元数据: {media_path} (大小: {media_file.stat().st_size} 字节)")
try:
result = await ffprobe.execute()
# 详细的错误信息
if result.returncode != 0:
stderr_text = result.stderr.decode() if result.stderr else 'No error output'
stdout_text = result.stdout.decode() if result.stdout else 'No output'
logger.error(f"FFprobe failed with return code {result.returncode}")
logger.error(f"FFprobe stderr: {stderr_text}")
logger.error(f"FFprobe stdout: {stdout_text}")
# 如果是空JSON说明文件可能不是有效的视频文件
if stdout_text.strip() in ['{}', '']:
raise RuntimeError(f"文件不是有效的视频文件或已损坏: {media_path}")
raise RuntimeError(f"FFprobe failed (code {result.returncode}): {stderr_text}")
# 检查输出是否为空
if not result.stdout:
raise RuntimeError("FFprobe returned empty output")
stdout_text = result.stdout.decode()
if not stdout_text.strip():
raise RuntimeError("FFprobe returned empty stdout")
# 解析JSON
try:
metadata_json = json.loads(stdout_text)
# 检查JSON是否包含有效数据
if not metadata_json or (not metadata_json.get('streams') and not metadata_json.get('format')):
raise RuntimeError(f"FFprobe返回空的元数据文件可能已损坏: {media_path}")
except json.JSONDecodeError as e:
logger.error(f"Failed to parse FFprobe JSON output: {e}")
logger.error(f"Raw output: {stdout_text[:500]}...")
raise RuntimeError(f"Invalid JSON from FFprobe: {e}")
logger.info(f"成功获取视频元数据: {media_path}")
return VideoMetadata.from_ffprobe(metadata_json)
except Exception as e:
logger.error(f"Failed to get video metadata for {media_path}: {e}")
raise
def _validate_segments(self, segments: List[SliceSegment], video_duration: float) -> None:
"""
验证切片段配置
Args:
segments: 切片段列表
video_duration: 视频总时长
"""
diff_tolerance = 0.001
for i, segment in enumerate(segments):
if segment.start > video_duration or segment.start < 0:
raise ValueError(
f"{i}个切割点起始点{segment.start}s超出视频时长[0-{video_duration}s]范围"
)
if segment.end > video_duration or segment.end < 0:
if segment.end > 0 and math.isclose(segment.end, video_duration, rel_tol=diff_tolerance):
# 允许小的误差
segment.end = video_duration
logger.warning(
f"{i}个切割点结束点{segment.end}s接近视频时长已调整为{video_duration}s"
)
else:
raise ValueError(
f"{i}个切割点结束点{segment.end}s超出视频时长[0-{video_duration}s]范围"
)
if segment.start >= segment.end:
raise ValueError(
f"{i}个切割点起始时间{segment.start}s必须小于结束时间{segment.end}s"
)
def _generate_output_path(self, base_path: str, index: int, extension: str = "mp4") -> str:
"""生成输出文件路径"""
base = Path(base_path)
return str(base.parent / f"{base.stem}_{index:03d}.{extension}")
async def slice_video(self,
media_path: str,
segments: List[SliceSegment],
options: SliceOptions,
output_path: Optional[str] = None) -> List[Tuple[str, VideoMetadata]]:
"""
使用本地视频文件按时间段切割出分段视频
Args:
media_path: 本地视频路径
segments: 分段起始结束时间标记列表
options: 输出切割质量选项
output_path: 最终输出文件路径片段会根据指定路径附加_001.mp4等片段编号
Returns:
List[Tuple[str, VideoMetadata]]: 输出片段的本地路径和元数据
"""
if not segments:
raise ValueError("No segments provided")
# 获取视频元数据
metadata = await self.get_video_metadata(media_path)
logger.info(f"视频信息: {metadata.width}x{metadata.height}, {metadata.duration:.2f}s, {metadata.fps}fps")
# 验证切片段
self._validate_segments(segments, metadata.duration)
# 准备输出路径
if not output_path:
output_path = str(Path(media_path).with_suffix('_slice.mp4'))
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# 创建FFmpeg命令
ffmpeg_cmd = self._create_async_ffmpeg_cmd()
if not ffmpeg_cmd:
raise RuntimeError("Failed to create FFmpeg command")
ffmpeg_cmd.input(media_path)
# 检查是否有音频流
has_audio = metadata.audio_codec is not None and metadata.audio_codec != ""
# 构建filter_complex
filter_complex = []
temp_outputs = []
for index, segment in enumerate(segments):
start = segment.start
end = segment.end
# 处理指定的输出分辨率
if options.width and options.height:
filter_complex.append(f"[v:0]trim=start={start}:end={end},scale={options.width}:{options.height},setpts=PTS-STARTPTS[cut{index}]")
if has_audio:
filter_complex.append(f"[a:0]atrim=start={start}:end={end},asetpts=PTS-STARTPTS[acut{index}]")
else:
filter_complex.append(f"[v:0]trim=start={start}:end={end},setpts=PTS-STARTPTS[cut{index}]")
if has_audio:
filter_complex.append(f"[a:0]atrim=start={start}:end={end},asetpts=PTS-STARTPTS[acut{index}]")
ffmpeg_cmd.option('filter_complex', ';'.join(filter_complex))
# 为每个片段配置输出
for i, segment in enumerate(segments):
segment_output_path = self._generate_output_path(output_path, i)
# 根据是否有音频流配置映射
if has_audio:
map_options = [f"[cut{i}]", f"[acut{i}]"]
else:
map_options = [f"[cut{i}]"]
ffmpeg_options = {
"map": map_options,
"reset_timestamps": "1",
"sc_threshold": "0",
"g": "1",
"force_key_frames": "expr:gte(t,n_forced*1)",
"vcodec": "libx264",
"crf": options.crf,
"r": options.fps,
"preset": options.preset
}
# 只有在有音频时才添加音频编码器
if has_audio:
ffmpeg_options["acodec"] = "aac"
if options.limit_size:
ffmpeg_options["fs"] = options.limit_size
elif options.bit_rate:
ffmpeg_options["b:v"] = options.pretty_bit_rate
ffmpeg_cmd.output(segment_output_path, options=ffmpeg_options)
temp_outputs.append(segment_output_path)
# 执行切片
try:
logger.info(f"开始执行FFmpeg切片命令...")
result = await ffmpeg_cmd.execute()
# 检查执行结果
if result.returncode != 0:
stderr_text = result.stderr.decode() if result.stderr else 'No error output'
stdout_text = result.stdout.decode() if result.stdout else 'No output'
logger.error(f"FFmpeg切片失败返回码: {result.returncode}")
logger.error(f"FFmpeg stderr: {stderr_text}")
logger.error(f"FFmpeg stdout: {stdout_text}")
raise RuntimeError(f"FFmpeg切片失败 (返回码 {result.returncode}): {stderr_text}")
logger.info(f"FFmpeg执行成功检查输出文件...")
# 验证输出文件是否真的被创建
missing_files = []
for output_file in temp_outputs:
if not Path(output_file).exists():
missing_files.append(output_file)
if missing_files:
logger.error(f"FFmpeg执行成功但输出文件未创建: {len(missing_files)} 个文件缺失")
for missing_file in missing_files[:5]: # 只显示前5个
logger.error(f" 缺失文件: {missing_file}")
raise RuntimeError(f"FFmpeg执行成功但未生成预期的输出文件缺失 {len(missing_files)} 个文件")
logger.info(f"视频切片完成: 生成{len(temp_outputs)}个片段")
except Exception as e:
logger.error(f"视频切片失败: {e}")
raise
# 获取输出文件的元数据
outputs = []
for output_file in temp_outputs:
try:
output_metadata = await self.get_video_metadata(output_file)
outputs.append((output_file, output_metadata))
except Exception as e:
logger.warning(f"无法获取输出文件元数据 {output_file}: {e}")
# 创建一个基本的元数据对象
basic_metadata = VideoMetadata(
duration=0, width=0, height=0, fps=0,
format_name="mp4", size=0, codec_name="h264",
audio_codec="aac" # 添加缺少的audio_codec参数
)
outputs.append((output_file, basic_metadata))
return outputs
async def slice_video_by_duration(self,
media_path: str,
segment_duration: float,
options: SliceOptions,
output_dir: Optional[str] = None,
overlap: float = 0.0) -> List[Tuple[str, VideoMetadata]]:
"""
按固定时长切割视频
Args:
media_path: 输入视频路径
segment_duration: 每段时长(秒)
options: 输出选项
output_dir: 输出目录
overlap: 片段重叠时间(秒)
Returns:
输出片段列表
"""
metadata = await self.get_video_metadata(media_path)
if not output_dir:
output_dir = str(Path(media_path).parent / f"{Path(media_path).stem}_segments")
# 计算切片段
segments = []
current_start = 0.0
while current_start < metadata.duration:
end_time = min(current_start + segment_duration, metadata.duration)
if end_time - current_start >= 1.0: # 至少1秒的片段
segments.append(SliceSegment(start=current_start, end=end_time))
current_start += segment_duration - overlap
if current_start >= metadata.duration:
break
logger.info(f"{segment_duration}s时长切割生成{len(segments)}个片段")
# 使用基础切片方法
output_path = os.path.join(output_dir, f"{Path(media_path).stem}_segment.mp4")
return await self.slice_video(media_path, segments, options, output_path)
async def slice_video_by_count(self,
media_path: str,
segment_count: int,
options: SliceOptions,
output_dir: Optional[str] = None) -> List[Tuple[str, VideoMetadata]]:
"""
按片段数量平均切割视频
Args:
media_path: 输入视频路径
segment_count: 片段数量
options: 输出选项
output_dir: 输出目录
Returns:
输出片段列表
"""
if segment_count <= 0:
raise ValueError("Segment count must be positive")
metadata = await self.get_video_metadata(media_path)
segment_duration = metadata.duration / segment_count
if not output_dir:
output_dir = str(Path(media_path).parent / f"{Path(media_path).stem}_segments")
# 计算切片段
segments = []
for i in range(segment_count):
start_time = i * segment_duration
end_time = min((i + 1) * segment_duration, metadata.duration)
if end_time - start_time >= 0.5: # 至少0.5秒的片段
segments.append(SliceSegment(start=start_time, end=end_time))
logger.info(f"平均切割为{len(segments)}个片段,每段约{segment_duration:.2f}s")
# 使用基础切片方法
output_path = os.path.join(output_dir, f"{Path(media_path).stem}_segment.mp4")
return await self.slice_video(media_path, segments, options, output_path)
async def batch_slice_videos(self,
video_files: List[str],
segment_duration: float,
options: SliceOptions,
output_base_dir: str,
max_concurrent: int = 3) -> Dict[str, Any]:
"""
批量切割多个视频
Args:
video_files: 视频文件列表
segment_duration: 每段时长
options: 输出选项
output_base_dir: 输出基础目录
max_concurrent: 最大并发数
Returns:
批处理结果
"""
os.makedirs(output_base_dir, exist_ok=True)
# 创建任务
async def process_single_video(video_file: str):
try:
file_name = Path(video_file).stem
output_dir = os.path.join(output_base_dir, file_name)
results = await self.slice_video_by_duration(
media_path=video_file,
segment_duration=segment_duration,
options=options,
output_dir=output_dir
)
return {
"file": video_file,
"success": True,
"segments": len(results),
"outputs": [path for path, _ in results]
}
except Exception as e:
logger.error(f"处理视频失败 {video_file}: {e}")
return {
"file": video_file,
"success": False,
"error": str(e)
}
# 并发执行
semaphore = asyncio.Semaphore(max_concurrent)
async def limited_task(video_file):
async with semaphore:
return await process_single_video(video_file)
logger.info(f"开始批量处理{len(video_files)}个视频文件")
results = await asyncio.gather(
*[limited_task(video_file) for video_file in video_files],
return_exceptions=True
)
# 统计结果
success_count = 0
error_count = 0
total_segments = 0
errors = []
for result in results:
if isinstance(result, Exception):
error_count += 1
errors.append({"error": str(result)})
elif result["success"]:
success_count += 1
total_segments += result["segments"]
else:
error_count += 1
errors.append(result)
batch_result = {
"total_files": len(video_files),
"success_count": success_count,
"error_count": error_count,
"total_segments": total_segments,
"results": results,
"errors": errors
}
logger.info(f"批量处理完成: 成功{success_count}, 失败{error_count}, 总片段{total_segments}")
return batch_result
def create_slice_options(self,
quality: str = "medium",
width: Optional[int] = None,
height: Optional[int] = None,
fps: int = 30,
bit_rate: Optional[str] = None,
limit_size: Optional[str] = None) -> SliceOptions:
"""
创建切片选项的便捷方法
Args:
quality: 质量预设 (low, medium, high)
width: 输出宽度
height: 输出高度
fps: 帧率
bit_rate: 比特率
limit_size: 文件大小限制
Returns:
SliceOptions对象
"""
quality_presets = {
"low": {"crf": 28, "preset": "fast"},
"medium": {"crf": 23, "preset": "medium"},
"high": {"crf": 18, "preset": "slow"},
"ultra": {"crf": 15, "preset": "veryslow"}
}
preset_config = quality_presets.get(quality, quality_presets["medium"])
return SliceOptions(
width=width,
height=height,
crf=preset_config["crf"],
fps=fps,
bit_rate=bit_rate,
limit_size=limit_size,
preset=preset_config["preset"]
)
async def _slice_video_fallback(self, media_path: str, segments: List[SliceSegment], output_path: str = None) -> List[Tuple[str, VideoMetadata]]:
"""
备用的视频切片实现(当 FFmpeg Python 不可用时)
Args:
media_path: 输入视频路径
segments: 要切片的段落列表
output_path: 输出路径(可选)
Returns:
List[Tuple[str, VideoMetadata]]: 输出片段的本地路径和元数据
"""
logger.warning("FFmpeg Python 不可用,使用备用实现(仅返回原视频信息)")
try:
# 获取视频基本信息
import os
from pathlib import Path
if not os.path.exists(media_path):
raise FileNotFoundError(f"视频文件不存在: {media_path}")
# 创建基本的视频元数据
file_size = os.path.getsize(media_path)
file_name = Path(media_path).name
# 创建简化的元数据
metadata = VideoMetadata(
duration=60.0, # 默认值
width=1920, # 默认值
height=1080, # 默认值
fps=30.0, # 默认值
format_name="mp4", # 默认值
size=file_size,
codec_name="h264", # 默认值
audio_codec="aac" # 默认值
)
# 为每个段落创建结果(实际上返回原视频)
results = []
for i, segment in enumerate(segments):
# 创建输出文件名
output_dir = Path(media_path).parent / "segments"
output_dir.mkdir(exist_ok=True)
segment_filename = f"{Path(media_path).stem}_segment_{i+1}_{segment.start_time:.1f}s-{segment.end_time:.1f}s.mp4"
segment_path = output_dir / segment_filename
# 在备用模式下,我们只是复制原文件(或创建一个占位符)
try:
import shutil
shutil.copy2(media_path, segment_path)
logger.info(f"备用模式:复制原视频到 {segment_path}")
except Exception as e:
logger.warning(f"复制文件失败: {e}")
# 创建一个空文件作为占位符
segment_path.touch()
# 创建段落元数据
segment_metadata = VideoMetadata(
width=metadata.width,
height=metadata.height,
duration=segment.end_time - segment.start_time,
fps=metadata.fps,
bitrate=metadata.bitrate,
codec=metadata.codec,
file_size=file_size, # 简化处理
format=metadata.format
)
results.append((str(segment_path), segment_metadata))
logger.info(f"备用模式完成,生成了 {len(results)} 个段落")
return results
except Exception as e:
logger.error(f"备用视频切片失败: {e}")
raise RuntimeError(f"视频切片失败: {e}")