fix: 视频切分

This commit is contained in:
root
2025-07-12 15:10:38 +08:00
parent d079d2a6a8
commit 5659bb34f3
4 changed files with 616 additions and 720 deletions

View File

@@ -41,9 +41,10 @@ class BatchSceneDetectionWorkflowState:
use_advanced_split: bool = True
split_quality: int = 23
split_preset: str = "fast"
max_video_duration: float = 60.0 # 最大视频时长默认60秒
# 批量处理配置
max_concurrent: int = 2
max_concurrent: int = 4
continue_on_error: bool = True
# 工作流状态

View File

@@ -5,9 +5,8 @@ Batch Workflow Nodes
"""
import time
import asyncio
from pathlib import Path
from typing import Dict, Any, List
from typing import Dict, Any
from concurrent.futures import ThreadPoolExecutor, as_completed
from python_core.utils.logger import logger
@@ -17,7 +16,7 @@ from ..types.enums import DetectorType, OutputFormat
from ..services.detector_service import SceneDetectorService
from ..services.video_info_service import VideoInfoService
from ..services.ai_analysis_service import AIAnalysisService
from python_core.services.ffmpeg_slice_service import FfmpegSliceService, SliceOptions
from python_core.services.ffmpeg_slice_service_sync import FfmpegSliceService, SliceOptions, SliceSegment
from ..utils.result_saver import ResultSaver
@@ -202,13 +201,100 @@ class BatchWorkflowNodes:
preset=state.split_preset
)
split_results_raw = self.splitter_service.split_video_by_scenes(
task.video_path,
detection_result.scenes,
task.output_dir / "scenes",
use_advanced=state.use_advanced_split,
options=slice_options
)
# 转换场景为SliceSegment
segments = [
SliceSegment(start=scene.start_time, end=scene.end_time)
for scene in detection_result.scenes
]
# 创建输出目录
scenes_dir = task.output_dir / "scenes"
scenes_dir.mkdir(parents=True, exist_ok=True)
# 生成输出路径
base_output_path = str(scenes_dir / f"{task.video_path.stem}_scene")
# 使用同步方法进行切分
# 检查是否无场景,如果是则跳过切分
if len(segments) <= 0:
logger.info(f"🔄 检测到 0 个场景切换,跳过切分,直接使用源文件")
# 创建输出目录
scenes_dir.mkdir(parents=True, exist_ok=True)
# 直接复制源文件作为结果
import shutil
source_file = task.video_path
target_file = scenes_dir / f"{task.video_path.stem}_scene_001.mp4"
try:
shutil.copy2(source_file, target_file)
logger.info(f"✅ 源文件已复制到: {target_file}")
# 获取源文件元数据
metadata = self.splitter_service.get_video_metadata(str(source_file))
# 创建模拟的切分结果
slice_results = [(str(target_file), metadata)]
# 修正场景统计:无场景切换 = 1个场景整个视频
detection_result.total_scenes = 1
except Exception as e:
logger.error(f"❌ 复制源文件失败: {e}")
slice_results = []
else:
logger.info(f"🎬 检测到 {len(segments)} 个场景,开始切分")
slice_results = self.splitter_service.slice_video(
media_path=str(task.video_path),
segments=segments,
options=slice_options,
output_path=base_output_path
)
# 转换为兼容格式
split_results_raw = []
# 创建一个简单的结果对象
class SplitResult:
def __init__(self, scene_index, output_path, start_time, end_time, duration, file_size, success, error=None):
self.scene_index = scene_index
self.output_path = Path(output_path)
self.start_time = start_time
self.end_time = end_time
self.duration = duration
self.file_size = file_size
self.success = success
self.error = error
for i, (output_path, metadata) in enumerate(slice_results):
if len(detection_result.scenes) == 0:
# 无场景切换的情况:整个视频作为一个场景
split_result = SplitResult(
scene_index=0,
output_path=output_path,
start_time=0.0,
end_time=metadata.duration,
duration=metadata.duration,
file_size=metadata.size,
success=Path(output_path).exists(),
error=None if Path(output_path).exists() else "文件不存在"
)
split_results_raw.append(split_result)
else:
# 有场景切换的情况
scene = detection_result.scenes[i] if i < len(detection_result.scenes) else None
if scene:
split_result = SplitResult(
scene_index=i,
output_path=output_path,
start_time=scene.start_time,
end_time=scene.end_time,
duration=scene.duration,
file_size=metadata.size,
success=Path(output_path).exists(),
error=None if Path(output_path).exists() else "文件不存在"
)
split_results_raw.append(split_result)
# 转换为字典格式
split_results = [
@@ -225,10 +311,18 @@ class BatchWorkflowNodes:
for r in split_results_raw
]
# 保存切分摘要
split_summary = self.splitter_service.create_split_summary(
task.video_path, split_results_raw
)
# 创建切分摘要
successful_results = [r for r in split_results_raw if r.success]
failed_results = [r for r in split_results_raw if not r.success]
split_summary = {
"video_path": str(task.video_path),
"total_scenes": len(split_results_raw),
"successful_splits": len(successful_results),
"failed_splits": len(failed_results),
"success_rate": len(successful_results) / len(split_results_raw) * 100 if split_results_raw else 0,
"total_output_size": sum(r.file_size for r in successful_results)
}
summary_file = task.output_dir / "split_summary.json"
import json
with open(summary_file, 'w', encoding='utf-8') as f:
@@ -238,6 +332,8 @@ class BatchWorkflowNodes:
logger.error(f"视频切分失败: {e}")
# 切分失败不影响整体任务成功
# 4. 添加视频时长检查,如果时长大于 最大视频时长 那么就要进行二次切分 确保 视频不大于 最大视频时长
task.end_time = time.time()
return {

View File

@@ -1,705 +0,0 @@
"""
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}")

View File

@@ -0,0 +1,504 @@
"""
FFmpeg视频切片服务 - 同步版本
基于原有的FfmpegSliceService但使用同步方法而不是异步。
"""
import subprocess
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 loguru import logger
from ffmpeg.ffmpeg import FFmpeg
@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, ffprobe_data: Dict[str, Any]) -> 'VideoMetadata':
"""从ffprobe数据创建VideoMetadata对象"""
format_info = ffprobe_data.get('format', {})
streams = ffprobe_data.get('streams', [])
video_stream = None
audio_stream = None
for stream in streams:
if stream.get('codec_type') == 'video' and not video_stream:
video_stream = stream
elif stream.get('codec_type') == 'audio' and not audio_stream:
audio_stream = stream
if not video_stream:
raise ValueError("No video stream found in the media file")
# 解析帧率
fps = 0.0
if video_stream.get('r_frame_rate'):
try:
fps_str = video_stream['r_frame_rate']
if '/' in fps_str:
num, den = fps_str.split('/')
fps = float(num) / float(den) if float(den) != 0 else 0.0
else:
fps = float(fps_str)
except (ValueError, ZeroDivisionError):
fps = 0.0
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_stream.get('codec_name', '') if audio_stream else ''
)
class FfmpegSliceService:
"""FFmpeg视频切片服务 - 同步版本"""
def __init__(self):
"""初始化服务"""
self.temp_dir = None
logger.info("FfmpegSliceService (同步版本) 初始化完成")
def get_video_metadata(self, media_path: str) -> VideoMetadata:
"""
获取视频元数据
Args:
media_path: 视频文件路径
Returns:
VideoMetadata: 视频元数据对象
"""
# 首先验证文件是否存在和有效
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}")
logger.info(f"开始获取视频元数据: {media_path} (大小: {media_file.stat().st_size} 字节)")
try:
# 构建ffprobe命令
cmd = [
'ffprobe',
'-v', 'quiet',
'-print_format', 'json',
'-show_streams',
'-show_format',
str(media_path)
]
# 执行ffprobe命令
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30
)
# 详细的错误信息
if result.returncode != 0:
stderr_text = result.stderr if result.stderr else 'No error output'
stdout_text = result.stdout 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}")
stdout_text = result.stdout
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 subprocess.TimeoutExpired:
logger.error(f"FFprobe timeout for {media_path}")
raise RuntimeError(f"FFprobe timeout for {media_path}")
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 < 0:
raise ValueError(f"Segment {i+1}: start time cannot be negative ({segment.start})")
if segment.end <= segment.start:
raise ValueError(f"Segment {i+1}: end time ({segment.end}) must be greater than start time ({segment.start})")
if segment.start > video_duration:
raise ValueError(f"Segment {i+1}: start time ({segment.start}) exceeds video duration ({video_duration})")
if segment.end > video_duration + diff_tolerance:
raise ValueError(f"Segment {i+1}: end time ({segment.end}) exceeds video duration ({video_duration})")
def _generate_output_path(self, base_path: str, index: int) -> str:
"""
生成输出文件路径
Args:
base_path: 基础路径
index: 片段索引
Returns:
str: 输出文件路径
"""
base = Path(base_path)
# 确保有扩展名
suffix = base.suffix if base.suffix else '.mp4'
return str(base.parent / f"{base.stem}_{index+1:03d}{suffix}")
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 = 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)
# 检查是否有音频流
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"[0:v]trim=start={start}:end={end},scale={options.width}:{options.height},setpts=PTS-STARTPTS[cut{index}]")
if has_audio:
filter_complex.append(f"[0:a]atrim=start={start}:end={end},asetpts=PTS-STARTPTS[acut{index}]")
else:
filter_complex.append(f"[0:v]trim=start={start}:end={end},setpts=PTS-STARTPTS[cut{index}]")
if has_audio:
filter_complex.append(f"[0:a]atrim=start={start}:end={end},asetpts=PTS-STARTPTS[acut{index}]")
segment_output_path = self._generate_output_path(output_path, index)
temp_outputs.append(segment_output_path)
# 构建完整的FFmpeg命令
cmd = [
'ffmpeg',
'-i', media_path,
'-filter_complex', ';'.join(filter_complex)
]
# 为每个片段添加映射和编码选项
for i, segment in enumerate(segments):
segment_output_path = temp_outputs[i]
# 根据是否有音频流配置映射
if has_audio:
cmd.extend(['-map', f'[cut{i}]', '-map', f'[acut{i}]'])
else:
cmd.extend(['-map', f'[cut{i}]'])
# 编码选项
cmd.extend([
'-c:v', 'libx264',
'-preset', options.preset,
'-crf', str(options.crf),
'-r', str(options.fps),
'-reset_timestamps', '1',
'-sc_threshold', '0',
'-g', '1',
'-force_key_frames', 'expr:gte(t,n_forced*1)'
])
# 只有在有音频时才添加音频编码器
if has_audio:
cmd.extend(['-c:a', 'aac'])
if options.limit_size:
cmd.extend(['-fs', options.limit_size])
elif options.bit_rate:
cmd.extend(['-b:v', options.pretty_bit_rate])
cmd.extend(['-y', segment_output_path])
# 执行切片
try:
logger.info(f"开始执行FFmpeg切片命令...")
logger.debug(f"FFmpeg命令: {' '.join(cmd[:10])}...") # 只显示前10个参数
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600 # 10分钟超时
)
# 检查执行结果
if result.returncode != 0:
stderr_text = result.stderr if result.stderr else 'No error output'
stdout_text = result.stdout 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 subprocess.TimeoutExpired:
logger.error("FFmpeg切片超时")
# 清理可能创建的部分文件
for output_file in temp_outputs:
try:
if Path(output_file).exists():
Path(output_file).unlink()
logger.info(f"清理临时文件: {output_file}")
except Exception as cleanup_error:
logger.warning(f"清理文件失败 {output_file}: {cleanup_error}")
raise RuntimeError("FFmpeg切片超时")
except Exception as e:
logger.error(f"FFmpeg切片过程中发生错误: {e}")
# 清理可能创建的部分文件
for output_file in temp_outputs:
try:
if Path(output_file).exists():
Path(output_file).unlink()
logger.info(f"清理临时文件: {output_file}")
except Exception as cleanup_error:
logger.warning(f"清理文件失败 {output_file}: {cleanup_error}")
raise
# 收集输出文件和元数据
outputs = []
for output_file in temp_outputs:
try:
output_metadata = 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.0,
width=metadata.width,
height=metadata.height,
fps=metadata.fps,
format_name="mp4",
size=Path(output_file).stat().st_size if Path(output_file).exists() else 0,
codec_name="h264",
audio_codec=metadata.audio_codec if has_audio else ""
)
outputs.append((output_file, basic_metadata))
logger.info(f"切片完成,生成 {len(outputs)} 个文件")
return outputs
def check_and_split_by_duration(self, video_path: str, max_duration: float,
options: SliceOptions, output_dir: str = None) -> List[Tuple[str, VideoMetadata]]:
"""
检查视频时长,如果超过最大时长则进行二次切分
Args:
video_path: 视频文件路径
max_duration: 最大允许时长(秒)
options: 切分选项
output_dir: 输出目录如果为None则使用视频文件所在目录
Returns:
List[Tuple[str, VideoMetadata]]: 切分后的文件路径和元数据列表
"""
# 获取视频元数据
metadata = self.get_video_metadata(video_path)
if metadata.duration <= max_duration:
# 时长未超过限制,直接返回原文件
logger.info(f"📏 视频时长 {metadata.duration:.2f}s 未超过限制 {max_duration:.2f}s无需二次切分")
return [(video_path, metadata)]
logger.info(f"⚠️ 视频时长 {metadata.duration:.2f}s 超过限制 {max_duration:.2f}s开始二次切分")
# 计算需要切分的段数
num_segments = int(metadata.duration / max_duration) + 1
segment_duration = metadata.duration / num_segments
logger.info(f"🔄 将切分为 {num_segments} 段,每段约 {segment_duration:.2f}s")
# 创建切分段
segments = []
for i in range(num_segments):
start_time = i * segment_duration
end_time = min((i + 1) * segment_duration, metadata.duration)
segments.append(SliceSegment(start=start_time, end=end_time))
# 准备输出路径
if output_dir is None:
output_dir = str(Path(video_path).parent)
video_name = Path(video_path).stem
base_output_path = str(Path(output_dir) / f"{video_name}_split")
# 执行切分
try:
results = self.slice_video(
media_path=video_path,
segments=segments,
options=options,
output_path=base_output_path
)
logger.info(f"✅ 二次切分完成,生成 {len(results)} 个文件")
return results
except Exception as e:
logger.error(f"❌ 二次切分失败: {e}")
# 如果二次切分失败,返回原文件
return [(video_path, metadata)]
def check_ffmpeg_available(self) -> bool:
"""检查FFmpeg是否可用"""
try:
result = subprocess.run(
['ffmpeg', '-version'],
capture_output=True,
text=True,
timeout=10
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def create_split_summary(self, video_path, results) -> dict:
"""创建切分摘要 - 兼容性方法"""
successful_results = [r for r in results if r.success]
failed_results = [r for r in results if not r.success]
return {
"video_path": str(video_path),
"total_scenes": len(results),
"successful_splits": len(successful_results),
"failed_splits": len(failed_results),
"success_rate": len(successful_results) / len(results) * 100 if results else 0,
"total_output_size": sum(r.file_size for r in successful_results)
}