""" 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}")