linoy
inital commit
ebfc6b3
#!/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()