418 lines
15 KiB
Python
Executable File
418 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""
|
|
Split video into scenes using PySceneDetect.
|
|
This script provides a command-line interface for splitting videos into scenes using various detection algorithms.
|
|
It supports multiple detection methods, preview image generation, and customizable parameters for fine-tuning
|
|
the scene detection process.
|
|
Basic usage:
|
|
# Split video using default content-based detection
|
|
scenes_split.py input.mp4 output_dir/
|
|
# Save 3 preview images per scene
|
|
scenes_split.py input.mp4 output_dir/ --save-images 3
|
|
# Process specific duration and filter short scenes
|
|
scenes_split.py input.mp4 output_dir/ --duration 60s --filter-shorter-than 2s
|
|
Advanced usage:
|
|
# Content detection with minimum scene length and frame skip
|
|
scenes_split.py input.mp4 output_dir/ --detector content --min-scene-length 30 --frame-skip 2
|
|
# Use adaptive detection with custom detector and detector parameters
|
|
scenes_split.py input.mp4 output_dir/ --detector adaptive --threshold 3.0 --adaptive-window 10
|
|
"""
|
|
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import List, Optional, Tuple
|
|
|
|
import typer
|
|
from scenedetect import (
|
|
AdaptiveDetector,
|
|
ContentDetector,
|
|
HistogramDetector,
|
|
SceneManager,
|
|
ThresholdDetector,
|
|
open_video,
|
|
)
|
|
from scenedetect.frame_timecode import FrameTimecode
|
|
from scenedetect.scene_manager import SceneDetector, write_scene_list_html
|
|
from scenedetect.scene_manager import save_images as save_scene_images
|
|
from scenedetect.stats_manager import StatsManager
|
|
from scenedetect.video_splitter import split_video_ffmpeg
|
|
|
|
app = typer.Typer(no_args_is_help=True, help="Split video into scenes using PySceneDetect.")
|
|
|
|
|
|
class DetectorType(str, Enum):
|
|
"""Available scene detection algorithms."""
|
|
|
|
CONTENT = "content" # Detects fast cuts using HSV color space
|
|
ADAPTIVE = "adaptive" # Detects fast two-phase cuts
|
|
THRESHOLD = "threshold" # Detects fast cuts/slow fades in from and out to a given threshold level
|
|
HISTOGRAM = "histogram" # Detects based on YUV histogram differences in adjacent frames
|
|
|
|
|
|
def create_detector(
|
|
detector_type: DetectorType,
|
|
threshold: Optional[float] = None,
|
|
min_scene_len: Optional[int] = None,
|
|
luma_only: Optional[bool] = None,
|
|
adaptive_window: Optional[int] = None,
|
|
fade_bias: Optional[float] = None,
|
|
) -> SceneDetector:
|
|
"""Create a scene detector based on the specified type and parameters.
|
|
Args:
|
|
detector_type: Type of detector to create
|
|
threshold: Detection threshold (meaning varies by detector)
|
|
min_scene_len: Minimum scene length in frames
|
|
luma_only: If True, only use brightness for content detection
|
|
adaptive_window: Window size for adaptive detection
|
|
fade_bias: Bias for fade in/out detection (-1.0 to 1.0)
|
|
Note: Parameters set to None will use the detector's built-in default values.
|
|
Returns:
|
|
Configured scene detector instance
|
|
"""
|
|
# Set common arguments
|
|
kwargs = {}
|
|
if threshold is not None:
|
|
kwargs["threshold"] = threshold
|
|
|
|
if min_scene_len is not None:
|
|
kwargs["min_scene_len"] = min_scene_len
|
|
|
|
match detector_type:
|
|
case DetectorType.CONTENT:
|
|
if luma_only is not None:
|
|
kwargs["luma_only"] = luma_only
|
|
return ContentDetector(**kwargs)
|
|
case DetectorType.ADAPTIVE:
|
|
if adaptive_window is not None:
|
|
kwargs["window_width"] = adaptive_window
|
|
if luma_only is not None:
|
|
kwargs["luma_only"] = luma_only
|
|
if "threshold" in kwargs:
|
|
# Special case for adaptive detector which uses different param name
|
|
kwargs["adaptive_threshold"] = kwargs.pop("threshold")
|
|
return AdaptiveDetector(**kwargs)
|
|
case DetectorType.THRESHOLD:
|
|
if fade_bias is not None:
|
|
kwargs["fade_bias"] = fade_bias
|
|
return ThresholdDetector(**kwargs)
|
|
case DetectorType.HISTOGRAM:
|
|
return HistogramDetector(**kwargs)
|
|
case _:
|
|
raise ValueError(f"Unknown detector type: {detector_type}")
|
|
|
|
|
|
def validate_output_dir(output_dir: str) -> Path:
|
|
"""Validate and create output directory if it doesn't exist.
|
|
Args:
|
|
output_dir: Path to the output directory
|
|
Returns:
|
|
Path object of the validated output directory
|
|
"""
|
|
path = Path(output_dir)
|
|
|
|
if path.exists() and not path.is_dir():
|
|
raise typer.BadParameter(f"{output_dir} exists but is not a directory")
|
|
|
|
return path
|
|
|
|
|
|
def parse_timecode(video: any, time_str: Optional[str]) -> Optional[FrameTimecode]:
|
|
"""Parse a timecode string into a FrameTimecode object.
|
|
Supports formats:
|
|
- Frames: '123'
|
|
- Seconds: '123s' or '123.45s'
|
|
- Timecode: '00:02:03' or '00:02:03.456'
|
|
Args:
|
|
video: Video object to get framerate from
|
|
time_str: String to parse, or None
|
|
Returns:
|
|
FrameTimecode object or None if input is None
|
|
"""
|
|
if time_str is None:
|
|
return None
|
|
|
|
try:
|
|
if time_str.endswith("s"):
|
|
# Seconds format
|
|
seconds = float(time_str[:-1])
|
|
return FrameTimecode(timecode=seconds, fps=video.frame_rate)
|
|
elif ":" in time_str:
|
|
# Timecode format
|
|
return FrameTimecode(timecode=time_str, fps=video.frame_rate)
|
|
else:
|
|
# Frame number format
|
|
return FrameTimecode(timecode=int(time_str), fps=video.frame_rate)
|
|
except ValueError as e:
|
|
raise typer.BadParameter(
|
|
f"Invalid timecode format: {time_str}. Use frames (123), "
|
|
f"seconds (123s/123.45s), or timecode (HH:MM:SS[.nnn])",
|
|
) from e
|
|
|
|
|
|
def detect_and_split_scenes( # noqa: PLR0913
|
|
video_path: str,
|
|
output_dir: Path,
|
|
detector_type: DetectorType,
|
|
threshold: Optional[float] = None,
|
|
min_scene_len: Optional[int] = None,
|
|
max_scenes: Optional[int] = None,
|
|
filter_shorter_than: Optional[str] = None,
|
|
skip_start: Optional[int] = None, # noqa: ARG001
|
|
skip_end: Optional[int] = None, # noqa: ARG001
|
|
save_images_per_scene: int = 0,
|
|
stats_file: Optional[str] = None,
|
|
luma_only: bool = False,
|
|
adaptive_window: Optional[int] = None,
|
|
fade_bias: Optional[float] = None,
|
|
downscale_factor: Optional[int] = None,
|
|
frame_skip: int = 0,
|
|
duration: Optional[str] = None,
|
|
) -> List[Tuple[FrameTimecode, FrameTimecode]]:
|
|
"""Detect and split scenes in a video using the specified parameters.
|
|
Args:
|
|
video_path: Path to input video.
|
|
output_dir: Directory to save output split scenes.
|
|
detector_type: Type of scene detector to use.
|
|
threshold: Detection threshold.
|
|
min_scene_len: Minimum scene length in frames.
|
|
max_scenes: Maximum number of scenes to detect.
|
|
filter_shorter_than: Filter out scenes shorter than this duration (frames/seconds/timecode)
|
|
skip_start: Number of frames to skip at start.
|
|
skip_end: Number of frames to skip at end.
|
|
save_images_per_scene: Number of images to save per scene (0 to disable).
|
|
stats_file: Path to save detection statistics (optional).
|
|
luma_only: Only use brightness for content detection.
|
|
adaptive_window: Window size for adaptive detection.
|
|
fade_bias: Bias for fade detection (-1.0 to 1.0).
|
|
downscale_factor: Factor to downscale frames by during detection.
|
|
frame_skip: Number of frames to skip (i.e. process every 1 in N+1 frames,
|
|
where N is frame_skip, processing only 1/N+1 percent of the video,
|
|
speeding up the detection time at the expense of accuracy).
|
|
frame_skip must be 0 (the default) when using a StatsManager.
|
|
duration: How much of the video to process from start position.
|
|
Can be specified as frames (123), seconds (123s/123.45s),
|
|
or timecode (HH:MM:SS[.nnn]).
|
|
Returns:
|
|
List of detected scenes as (start, end) FrameTimecode pairs.
|
|
"""
|
|
# Create video stream
|
|
video = open_video(video_path, backend="opencv")
|
|
|
|
# Parse duration if specified
|
|
duration_tc = parse_timecode(video, duration)
|
|
|
|
# Parse filter_shorter_than if specified
|
|
filter_shorter_than_tc = parse_timecode(video, filter_shorter_than)
|
|
|
|
# Initialize scene manager with optional stats manager
|
|
stats_manager = StatsManager() if stats_file else None
|
|
scene_manager = SceneManager(stats_manager)
|
|
|
|
# Configure scene manager
|
|
if downscale_factor:
|
|
scene_manager.auto_downscale = False
|
|
scene_manager.downscale = downscale_factor
|
|
|
|
# Create and add detector
|
|
detector = create_detector(
|
|
detector_type=detector_type,
|
|
threshold=threshold,
|
|
min_scene_len=min_scene_len,
|
|
luma_only=luma_only,
|
|
adaptive_window=adaptive_window,
|
|
fade_bias=fade_bias,
|
|
)
|
|
scene_manager.add_detector(detector)
|
|
|
|
# Detect scenes
|
|
typer.echo("Detecting scenes...")
|
|
scene_manager.detect_scenes(
|
|
video=video,
|
|
show_progress=True,
|
|
frame_skip=frame_skip,
|
|
duration=duration_tc,
|
|
)
|
|
|
|
# Get scene list
|
|
scenes = scene_manager.get_scene_list()
|
|
|
|
# Filter out scenes that are too short if filter_shorter_than is specified
|
|
if filter_shorter_than_tc:
|
|
original_count = len(scenes)
|
|
scenes = [
|
|
(start, end)
|
|
for start, end in scenes
|
|
if (end.get_frames() - start.get_frames()) >= filter_shorter_than_tc.get_frames()
|
|
]
|
|
if len(scenes) < original_count:
|
|
typer.echo(
|
|
f"Filtered out {original_count - len(scenes)} scenes shorter "
|
|
f"than {filter_shorter_than_tc.get_seconds():.1f} seconds "
|
|
f"({filter_shorter_than_tc.get_frames()} frames)",
|
|
)
|
|
|
|
# Apply max scenes limit if specified
|
|
if max_scenes and len(scenes) > max_scenes:
|
|
typer.echo(f"Dropping last {len(scenes) - max_scenes} scenes to meet max_scenes ({max_scenes}) limit")
|
|
scenes = scenes[:max_scenes]
|
|
|
|
# Print scene information
|
|
typer.echo(f"Found {len(scenes)} scenes:")
|
|
for i, (start, end) in enumerate(scenes, 1):
|
|
typer.echo(
|
|
f"Scene {i}: {start.get_timecode()} to {end.get_timecode()} "
|
|
f"({end.get_frames() - start.get_frames()} frames)",
|
|
)
|
|
|
|
# Save stats if requested
|
|
if stats_file:
|
|
typer.echo(f"Saving detection stats to {stats_file}")
|
|
stats_manager.save_to_csv(stats_file)
|
|
|
|
# Split video into scenes
|
|
typer.echo("Splitting video into scenes...")
|
|
try:
|
|
split_video_ffmpeg(
|
|
input_video_path=video_path,
|
|
scene_list=scenes,
|
|
output_dir=output_dir,
|
|
show_progress=True,
|
|
)
|
|
typer.echo(f"Scenes have been saved to: {output_dir}")
|
|
except Exception as e:
|
|
raise typer.BadParameter(f"Error splitting video: {e}") from e
|
|
|
|
# Save preview images if requested
|
|
if save_images_per_scene > 0:
|
|
typer.echo(f"Saving {save_images_per_scene} preview images per scene...")
|
|
image_filenames = save_scene_images(
|
|
scene_list=scenes,
|
|
video=video,
|
|
num_images=save_images_per_scene,
|
|
output_dir=str(output_dir),
|
|
show_progress=True,
|
|
)
|
|
|
|
# Generate HTML report with scene information and previews
|
|
html_path = output_dir / "scene_report.html"
|
|
write_scene_list_html(
|
|
output_html_filename=str(html_path),
|
|
scene_list=scenes,
|
|
image_filenames=image_filenames,
|
|
)
|
|
typer.echo(f"Scene report saved to: {html_path}")
|
|
|
|
return scenes
|
|
|
|
|
|
@app.command()
|
|
def main( # noqa: PLR0913
|
|
video_path: Path = typer.Argument( # noqa: B008
|
|
...,
|
|
help="Path to the input video file",
|
|
exists=True,
|
|
dir_okay=False,
|
|
),
|
|
output_dir: str = typer.Argument(
|
|
...,
|
|
help="Directory where split scenes will be saved",
|
|
),
|
|
detector: DetectorType = typer.Option( # noqa: B008
|
|
DetectorType.CONTENT,
|
|
help="Scene detection algorithm to use",
|
|
),
|
|
threshold: Optional[float] = typer.Option(
|
|
None,
|
|
help="Detection threshold (meaning varies by detector)",
|
|
),
|
|
max_scenes: Optional[int] = typer.Option(
|
|
None,
|
|
help="Maximum number of scenes to produce",
|
|
),
|
|
min_scene_length: Optional[int] = typer.Option(
|
|
None,
|
|
help="Minimum scene length during detection. Forces the detector to make scenes at least this many frames. "
|
|
"This affects scene detection behavior but does not filter out short scenes.",
|
|
),
|
|
filter_shorter_than: Optional[str] = typer.Option(
|
|
None,
|
|
help="Filter out scenes shorter than this duration. Can be specified as frames (123), "
|
|
"seconds (123s/123.45s), or timecode (HH:MM:SS[.nnn]). These scenes will be detected but not saved.",
|
|
),
|
|
skip_start: Optional[int] = typer.Option(
|
|
None,
|
|
help="Number of frames to skip at the start of the video",
|
|
),
|
|
skip_end: Optional[int] = typer.Option(
|
|
None,
|
|
help="Number of frames to skip at the end of the video",
|
|
),
|
|
duration: Optional[str] = typer.Option(
|
|
None,
|
|
"-d",
|
|
help="How much of the video to process. Can be specified as frames (123), "
|
|
"seconds (123s/123.45s), or timecode (HH:MM:SS[.nnn])",
|
|
),
|
|
save_images: int = typer.Option(
|
|
0,
|
|
help="Number of preview images to save per scene (0 to disable)",
|
|
),
|
|
stats_file: Optional[str] = typer.Option(
|
|
None,
|
|
help="Path to save detection statistics CSV",
|
|
),
|
|
luma_only: bool = typer.Option(
|
|
False,
|
|
help="Only use brightness for content detection",
|
|
),
|
|
adaptive_window: Optional[int] = typer.Option(
|
|
None,
|
|
help="Window size for adaptive detection",
|
|
),
|
|
fade_bias: Optional[float] = typer.Option(
|
|
None,
|
|
help="Bias for fade detection (-1.0 to 1.0)",
|
|
),
|
|
downscale: Optional[int] = typer.Option(
|
|
None,
|
|
help="Factor to downscale frames by during detection",
|
|
),
|
|
frame_skip: int = typer.Option(
|
|
0,
|
|
help="Number of frames to skip during processing",
|
|
),
|
|
) -> None:
|
|
"""Split video into scenes using PySceneDetect."""
|
|
if skip_start or skip_end:
|
|
typer.echo("Skipping start and end frames is not supported yet.")
|
|
return
|
|
|
|
# Validate output directory
|
|
output_path = validate_output_dir(output_dir)
|
|
|
|
# Detect and split scenes
|
|
detect_and_split_scenes(
|
|
video_path=str(video_path),
|
|
output_dir=output_path,
|
|
detector_type=detector,
|
|
threshold=threshold,
|
|
min_scene_len=min_scene_length,
|
|
max_scenes=max_scenes,
|
|
filter_shorter_than=filter_shorter_than,
|
|
skip_start=skip_start,
|
|
skip_end=skip_end,
|
|
duration=duration,
|
|
save_images_per_scene=save_images,
|
|
stats_file=stats_file,
|
|
luma_only=luma_only,
|
|
adaptive_window=adaptive_window,
|
|
fade_bias=fade_bias,
|
|
downscale_factor=downscale,
|
|
frame_skip=frame_skip,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|