706 lines
26 KiB
Python
706 lines
26 KiB
Python
"""
|
||
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}")
|