Automated PR - 2026-01-05
This commit is contained in:
Executable
+417
@@ -0,0 +1,417 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user