473 lines
17 KiB
Python
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()
|