Files
mxivideo/python_core/services/video_splitter_enhanced.py
2025-07-11 20:38:09 +08:00

473 lines
17 KiB
Python

#!/usr/bin/env python3
"""
高质量的PySceneDetect视频拆分服务
应用设计模式、错误处理、类型安全等最佳实践
"""
import sys
from abc import ABC, abstractmethod
from pathlib import Path
from typing import List, Dict, Optional, Protocol, Union, Any
from dataclasses import dataclass, asdict, field
from datetime import datetime
from contextlib import contextmanager
from enum import Enum
import logging
# 导入通用工具
try:
from python_core.utils.command_utils import (
DependencyChecker, CommandLineParser, JSONRPCHandler,
FileUtils, PerformanceUtils, create_command_service_base
)
from python_core.utils.logger import logger
UTILS_AVAILABLE = True
except ImportError:
# 优雅降级
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
UTILS_AVAILABLE = False
# 类型定义
class DetectorType(Enum):
"""检测器类型枚举"""
CONTENT = "content"
THRESHOLD = "threshold"
class ServiceError(Exception):
"""服务基础异常"""
def __init__(self, message: str, error_code: str = "UNKNOWN_ERROR"):
super().__init__(message)
self.error_code = error_code
self.message = message
class DependencyError(ServiceError):
"""依赖缺失异常"""
def __init__(self, dependency: str):
super().__init__(f"Required dependency not available: {dependency}", "DEPENDENCY_ERROR")
class ValidationError(ServiceError):
"""验证错误异常"""
def __init__(self, message: str):
super().__init__(message, "VALIDATION_ERROR")
@dataclass(frozen=True)
class SceneInfo:
"""场景信息 - 不可变数据类"""
scene_number: int
start_time: float
end_time: float
duration: float
start_frame: int
end_frame: int
def __post_init__(self):
"""数据验证"""
if self.scene_number <= 0:
raise ValidationError("Scene number must be positive")
if self.start_time < 0 or self.end_time < 0:
raise ValidationError("Time values must be non-negative")
if self.start_time >= self.end_time:
raise ValidationError("Start time must be less than end time")
if abs(self.duration - (self.end_time - self.start_time)) > 0.01:
raise ValidationError("Duration must match time difference")
@dataclass
class AnalysisResult:
"""分析结果"""
success: bool
video_path: str
total_scenes: int = 0
total_duration: float = 0.0
average_scene_duration: float = 0.0
scenes: List[SceneInfo] = field(default_factory=list)
analysis_time: float = 0.0
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
result = asdict(self)
result['scenes'] = [asdict(scene) for scene in self.scenes]
return result
@dataclass
class DetectionConfig:
"""检测配置"""
threshold: float = 30.0
detector_type: DetectorType = DetectorType.CONTENT
min_scene_length: float = 1.0 # 最小场景长度(秒)
def __post_init__(self):
"""配置验证"""
if not 0 < self.threshold <= 100:
raise ValidationError("Threshold must be between 0 and 100")
if self.min_scene_length < 0:
raise ValidationError("Minimum scene length must be non-negative")
# 协议定义
class SceneDetector(Protocol):
"""场景检测器协议"""
def detect_scenes(self, video_path: str, config: DetectionConfig) -> List[SceneInfo]:
"""检测场景"""
...
class VideoValidator(Protocol):
"""视频验证器协议"""
def validate(self, video_path: str) -> bool:
"""验证视频文件"""
...
# 具体实现
class PySceneDetectDetector:
"""PySceneDetect场景检测器实现"""
def __init__(self):
self._check_dependencies()
def _check_dependencies(self) -> None:
"""检查依赖"""
if not UTILS_AVAILABLE:
# 简化版依赖检查
try:
import scenedetect
self.scenedetect = scenedetect
except ImportError:
raise DependencyError("PySceneDetect")
else:
# 使用通用工具检查
available, items = DependencyChecker.check_optional_dependency(
module_name="scenedetect",
import_items=["VideoManager", "SceneManager", "detectors.ContentDetector", "detectors.ThresholdDetector"],
success_message="PySceneDetect is available",
error_message="PySceneDetect not available"
)
if not available:
raise DependencyError("PySceneDetect")
self._scenedetect_items = items
@contextmanager
def _video_manager(self, video_path: str):
"""视频管理器上下文管理器"""
if UTILS_AVAILABLE:
VideoManager = self._scenedetect_items["VideoManager"]
else:
from scenedetect import VideoManager
video_manager = VideoManager([video_path])
try:
video_manager.start()
yield video_manager
finally:
video_manager.release()
def detect_scenes(self, video_path: str, config: DetectionConfig) -> List[SceneInfo]:
"""检测场景"""
logger.info(f"Detecting scenes: {video_path}, threshold: {config.threshold}")
if UTILS_AVAILABLE:
SceneManager = self._scenedetect_items["SceneManager"]
ContentDetector = self._scenedetect_items["ContentDetector"]
ThresholdDetector = self._scenedetect_items["ThresholdDetector"]
else:
from scenedetect import SceneManager
from scenedetect.detectors import ContentDetector, ThresholdDetector
with self._video_manager(video_path) as video_manager:
scene_manager = SceneManager()
# 添加检测器
if config.detector_type == DetectorType.CONTENT:
scene_manager.add_detector(ContentDetector(threshold=config.threshold))
else:
scene_manager.add_detector(ThresholdDetector(threshold=config.threshold))
# 执行检测
scene_manager.detect_scenes(frame_source=video_manager)
scene_list = scene_manager.get_scene_list()
# 转换结果
scenes = self._convert_scenes(scene_list, video_manager, config)
if not scenes:
# 创建单个场景
scenes = self._create_single_scene(video_manager)
logger.info(f"Detected {len(scenes)} scenes")
return scenes
def _convert_scenes(self, scene_list: List, video_manager, config: DetectionConfig) -> List[SceneInfo]:
"""转换场景列表"""
scenes = []
for i, (start_time, end_time) in enumerate(scene_list):
duration = end_time.get_seconds() - start_time.get_seconds()
# 过滤太短的场景
if duration < config.min_scene_length:
logger.debug(f"Skipping short scene {i+1}: {duration:.2f}s")
continue
scene_info = SceneInfo(
scene_number=len(scenes) + 1, # 重新编号
start_time=start_time.get_seconds(),
end_time=end_time.get_seconds(),
duration=duration,
start_frame=start_time.get_frames(),
end_frame=end_time.get_frames()
)
scenes.append(scene_info)
return scenes
def _create_single_scene(self, video_manager) -> List[SceneInfo]:
"""创建单个场景"""
try:
duration_info = video_manager.get_duration()
fps = video_manager.get_framerate()
if isinstance(duration_info, tuple):
total_frames, fps = duration_info
total_duration = total_frames / fps if fps > 0 else 0
else:
total_duration = duration_info.get_seconds() if hasattr(duration_info, 'get_seconds') else float(duration_info)
total_frames = int(total_duration * fps) if fps > 0 else 0
return [SceneInfo(
scene_number=1,
start_time=0.0,
end_time=total_duration,
duration=total_duration,
start_frame=0,
end_frame=total_frames
)]
except Exception as e:
logger.warning(f"Failed to create single scene: {e}")
return []
class BasicVideoValidator:
"""基础视频验证器"""
SUPPORTED_EXTENSIONS = {'.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm'}
def validate(self, video_path: str) -> bool:
"""验证视频文件"""
path = Path(video_path)
# 检查文件存在
if not path.exists():
raise ValidationError(f"Video file not found: {video_path}")
# 检查是否为文件
if not path.is_file():
raise ValidationError(f"Path is not a file: {video_path}")
# 检查扩展名
if path.suffix.lower() not in self.SUPPORTED_EXTENSIONS:
logger.warning(f"Unsupported video extension: {path.suffix}")
# 检查文件大小
if path.stat().st_size == 0:
raise ValidationError(f"Video file is empty: {video_path}")
return True
class VideoSplitterService:
"""高质量的视频拆分服务"""
def __init__(self,
detector: Optional[SceneDetector] = None,
validator: Optional[VideoValidator] = None,
output_base_dir: Optional[str] = None):
"""
初始化服务
Args:
detector: 场景检测器
validator: 视频验证器
output_base_dir: 输出基础目录
"""
self.detector = detector or PySceneDetectDetector()
self.validator = validator or BasicVideoValidator()
self.output_base_dir = Path(output_base_dir) if output_base_dir else Path("./video_splits")
self.output_base_dir.mkdir(parents=True, exist_ok=True)
def analyze_video(self, video_path: str, config: Optional[DetectionConfig] = None) -> AnalysisResult:
"""
分析视频
Args:
video_path: 视频路径
config: 检测配置
Returns:
分析结果
"""
config = config or DetectionConfig()
try:
# 验证输入
self.validator.validate(video_path)
# 执行检测
if UTILS_AVAILABLE:
scenes, execution_time = PerformanceUtils.time_operation(
self.detector.detect_scenes, video_path, config
)
else:
import time
start_time = time.time()
scenes = self.detector.detect_scenes(video_path, config)
execution_time = time.time() - start_time
# 计算统计信息
total_duration = sum(scene.duration for scene in scenes)
average_duration = total_duration / len(scenes) if scenes else 0
return AnalysisResult(
success=True,
video_path=video_path,
total_scenes=len(scenes),
total_duration=total_duration,
average_scene_duration=average_duration,
scenes=scenes,
analysis_time=execution_time
)
except Exception as e:
logger.error(f"Video analysis failed: {e}")
return AnalysisResult(
success=False,
video_path=video_path,
error=str(e)
)
# 命令行接口
class CommandLineInterface:
"""命令行接口"""
def __init__(self):
self.service = None
self.rpc_handler = None
def setup_service(self, output_base: Optional[str] = None) -> None:
"""设置服务"""
try:
self.service = VideoSplitterService(output_base_dir=output_base)
except DependencyError as e:
logger.error(f"Service setup failed: {e}")
sys.exit(1)
def setup_rpc_handler(self) -> None:
"""设置RPC处理器"""
if UTILS_AVAILABLE:
try:
service_config = create_command_service_base(
service_name="video_splitter_enhanced",
optional_dependencies={
"jsonrpc": {
"module_name": "python_core.utils.jsonrpc",
"import_items": ["create_response_handler"],
}
}
)
if "jsonrpc" in service_config.get("dependencies", {}):
create_response_handler = service_config["dependencies"]["jsonrpc"]["create_response_handler"]
self.rpc_handler = create_response_handler()
except Exception as e:
logger.warning(f"RPC setup failed: {e}")
def parse_arguments(self) -> tuple[str, str, DetectionConfig]:
"""解析命令行参数"""
if len(sys.argv) < 3:
print("Usage: python video_splitter_enhanced.py <command> <video_path> [options...]")
sys.exit(1)
command = sys.argv[1]
video_path = sys.argv[2]
# 解析配置
if UTILS_AVAILABLE:
arg_definitions = {
"threshold": {"type": float, "default": 30.0},
"detector": {"type": str, "default": "content", "choices": ["content", "threshold"]},
"min-scene-length": {"type": float, "default": 1.0},
"output-base": {"type": str, "default": None}
}
try:
parsed_args = CommandLineParser.parse_command_args(sys.argv[3:], arg_definitions)
config = DetectionConfig(
threshold=parsed_args["threshold"],
detector_type=DetectorType(parsed_args["detector"]),
min_scene_length=parsed_args["min_scene_length"]
)
return command, video_path, config, parsed_args.get("output_base")
except (ValueError, ValidationError) as e:
logger.error(f"Argument error: {e}")
sys.exit(1)
else:
# 简化版参数解析
config = DetectionConfig()
return command, video_path, config, None
def handle_response(self, result: Dict[str, Any], error_code: str) -> None:
"""处理响应"""
if UTILS_AVAILABLE and self.rpc_handler:
JSONRPCHandler.handle_command_response(self.rpc_handler, result, error_code)
else:
import json
print(json.dumps(result, indent=2, ensure_ascii=False))
def run(self) -> None:
"""运行命令行接口"""
# 解析参数
command, video_path, config, output_base = self.parse_arguments()
# 设置服务
self.setup_service(output_base)
self.setup_rpc_handler()
# 执行命令
try:
if command == "analyze":
result = self.service.analyze_video(video_path, config)
self.handle_response(result.to_dict(), "ANALYSIS_FAILED")
elif command == "detect_scenes":
result = self.service.analyze_video(video_path, config)
# 只返回场景信息
scenes_result = {
"success": result.success,
"video_path": result.video_path,
"total_scenes": result.total_scenes,
"scenes": [asdict(scene) for scene in result.scenes],
"detection_settings": asdict(config),
"detection_time": result.analysis_time
}
if not result.success:
scenes_result["error"] = result.error
self.handle_response(scenes_result, "DETECTION_FAILED")
else:
error_msg = f"Unknown command: {command}. Available: analyze, detect_scenes"
if self.rpc_handler:
self.rpc_handler.error("INVALID_COMMAND", error_msg)
else:
logger.error(error_msg)
sys.exit(1)
except Exception as e:
logger.error(f"Command execution failed: {e}")
if self.rpc_handler:
self.rpc_handler.error("INTERNAL_ERROR", str(e))
else:
sys.exit(1)
def main():
"""主函数"""
cli = CommandLineInterface()
cli.run()
if __name__ == "__main__":
main()