Files
mediahive/hivescan/showreel.py
T

705 lines
24 KiB
Python

"""
Showreel generation module for media preview clips.
Generates short video clips (reels) from movies and TV episodes using ffmpeg.
Supports automatic black bar detection and removal, hardware-accelerated encoding,
and HDR passthrough.
"""
import json
import re
import shlex
import subprocess
from collections import Counter
from pathlib import Path
from typing import Optional
from tqdm import tqdm
# Showreel timestamp positions in seconds (5, 10, 15, 20, 25 minutes)
SHOWREEL_TIMESTAMPS = [5 * 60, 10 * 60, 15 * 60, 20 * 60, 25 * 60]
def get_expected_showreel_paths(
media_folder: Path,
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
media_root: Optional[Path] = None,
) -> list[str]:
"""
Compute the expected showreel paths without generating them.
Args:
media_folder: Folder for this specific media item
timestamps: List of timestamps (determines number of reels)
media_root: Root path for computing relative paths (optional)
Returns:
List of relative paths where showreels will be created
"""
paths = []
for reel_num in range(1, len(timestamps) + 1):
output_path = media_folder / f"reel{reel_num}.webm"
if media_root:
try:
paths.append(str(output_path.relative_to(media_root)))
except ValueError:
paths.append(str(output_path))
else:
paths.append(str(output_path))
return paths
def get_expected_episode_reel_path(
media_folder: Path,
season_num: int,
episode_num: int,
media_root: Optional[Path] = None,
) -> str:
"""
Compute the expected episode reel path without generating it.
Args:
media_folder: Folder for this series
season_num: Season number
episode_num: Episode number
media_root: Root path for computing relative paths (optional)
Returns:
Relative path where the reel will be created
"""
output_path = media_folder / f"S{season_num:02d}E{episode_num:02d}.webm"
if media_root:
try:
return str(output_path.relative_to(media_root))
except ValueError:
return str(output_path)
return str(output_path)
def movie_showreels_exist(media_folder: Path, timestamps: list[int] = SHOWREEL_TIMESTAMPS) -> bool:
"""Check if all showreel files for a movie already exist."""
for reel_num in range(1, len(timestamps) + 1):
if not (media_folder / f"reel{reel_num}.webm").exists():
return False
return True
def episode_reel_exists(media_folder: Path, season_num: int, episode_num: int) -> bool:
"""Check if an episode reel file already exists."""
return (media_folder / f"S{season_num:02d}E{episode_num:02d}.webm").exists()
def get_bluray_uri(video_path: str) -> Optional[str]:
"""
Convert a Blu-ray index.bdmv path to an ffmpeg-compatible bluray: URI.
Args:
video_path: Path that may be a Blu-ray index.bdmv file
Returns:
bluray: URI if this is a Blu-ray disc, None otherwise
"""
if not video_path.endswith(".bdmv"):
return None
path = Path(video_path)
# index.bdmv is in BDMV folder, so parent's parent is the disc root
# e.g., /path/to/disc/BDMV/index.bdmv -> /path/to/disc
if path.parent.name == "BDMV":
disc_root = path.parent.parent
return f"bluray:{disc_root}"
return None
# Cache for AV1 encoder availability
_av1_encoder_cache: Optional[str] = None
def get_av1_encoder() -> str:
"""
Detect the best available AV1 encoder.
Prefers hardware encoders (NVIDIA av1_nvenc) over software (libsvtav1).
Falls back to libsvtav1 if no hardware encoder is available.
Returns:
Encoder name to use with ffmpeg -c:v
"""
global _av1_encoder_cache
if _av1_encoder_cache is not None:
return _av1_encoder_cache
# Check for NVIDIA AV1 encoder
try:
result = subprocess.run(
["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True,
text=True,
timeout=10
)
if "av1_nvenc" in result.stdout:
# Verify it actually works (driver support)
test_result = subprocess.run(
["ffmpeg", "-f", "lavfi", "-i", "nullsrc=s=64x64:d=1", "-c:v", "av1_nvenc", "-f", "null", "-"],
capture_output=True,
timeout=10
)
if test_result.returncode == 0:
_av1_encoder_cache = "av1_nvenc"
return _av1_encoder_cache
except Exception:
pass
# Default to libsvtav1
_av1_encoder_cache = "libsvtav1"
return _av1_encoder_cache
def get_encoder_options(encoder: str) -> list[str]:
"""
Get encoder-specific options for the given AV1 encoder.
Args:
encoder: The encoder name (av1_nvenc, libsvtav1)
Returns:
List of ffmpeg arguments for encoder settings
"""
if encoder == "av1_nvenc":
# NVIDIA hardware encoder - use constant quality mode
return ["-cq", "35", "-preset", "p4"]
else:
# libsvtav1 software encoder
return ["-crf", "38", "-preset", "6"]
def detect_dovi_profile(video_path: str) -> Optional[int]:
"""
Detect Dolby Vision profile from a video file.
Returns the DoVi profile number (5, 7, 8, etc.) or None if not DoVi.
Profile 5: Dual-layer, no HDR10 base (needs conversion)
Profile 7: Dual-layer with HDR10 base, but may have EL issues
Profile 8: Single-layer HDR10 compatible (usually OK)
"""
try:
# Check for Dolby Vision configuration record in video stream
cmd = [
"ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream_side_data_list",
"-of", "json", video_path
]
print(f" $ {shlex.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
return None
data = json.loads(result.stdout)
streams = data.get("streams", [])
if not streams:
return None
# Look for DOVI configuration in side data
side_data_list = streams[0].get("side_data_list", [])
for side_data in side_data_list:
side_data_type = side_data.get("side_data_type", "")
if "DOVI" in side_data_type or "Dolby Vision" in side_data_type:
# Try to extract profile from dv_profile field
dv_profile = side_data.get("dv_profile")
if dv_profile is not None:
return int(dv_profile)
# Alternative: check using mediainfo-style detection via codec tag
# Some DoVi content has "dvhe" or "dvh1" codec tags
codec_cmd = [
"ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=codec_tag_string,codec_name",
"-of", "csv=p=0", video_path
]
codec_result = subprocess.run(codec_cmd, capture_output=True, text=True, timeout=30)
if codec_result.returncode == 0:
codec_info = codec_result.stdout.lower()
if "dvhe" in codec_info or "dvh1" in codec_info or "dav1" in codec_info:
# DoVi detected but profile unknown, assume needs conversion
return 7 # Conservative: treat as dual-layer
return None
except Exception as e:
print(f" DoVi detection error: {e}")
return None
def get_dovi_to_hdr10_filter() -> str:
"""
Get the video filter string for converting DoVi to HDR10.
Uses libplacebo to strip DoVi metadata while preserving HDR10 colorspace.
No tonemapping is applied - this just converts the container format.
"""
# libplacebo converts DoVi to clean HDR10 without tonemapping
# Preserves bt2020 primaries and SMPTE ST 2084 (PQ) transfer
return (
"libplacebo=colorspace=bt2020nc:color_primaries=bt2020:"
"color_trc=smpte2084:range=tv"
)
def is_hdr_video(video_path: str) -> bool:
"""
Check if a video file is HDR using ffprobe.
Returns True if the video has HDR metadata (bt2020, SMPTE ST 2084, etc.)
"""
try:
result = subprocess.run(
[
"ffprobe", "-v", "quiet", "-select_streams", "v:0",
"-show_entries", "stream=color_transfer,color_primaries,color_space",
"-of", "json", video_path
],
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
return False
data = json.loads(result.stdout)
streams = data.get("streams", [])
if not streams:
return False
stream = streams[0]
color_transfer = stream.get("color_transfer", "")
color_primaries = stream.get("color_primaries", "")
# HDR indicators
hdr_transfers = ["smpte2084", "arib-std-b67"] # PQ and HLG
hdr_primaries = ["bt2020"]
return color_transfer in hdr_transfers or color_primaries in hdr_primaries
except Exception:
return False
def detect_crop(video_path: str) -> Optional[str]:
"""
Detect black bars in a video and return the crop filter string.
Only runs on 16:9 (1.78:1) source videos, since other aspect ratios like
2.35:1 or 4:3 are already correctly framed. Trusts cropping results only
when symmetric (same top/bottom OR same left/right). Final coordinates
are aligned to 8 pixels.
Uses ffmpeg to analyze just 2 seconds of video at the 5-minute mark for speed.
Args:
video_path: Path to the video file (or bluray: URI)
Returns:
Crop filter string like "crop=1920:800:0:140" if black bars detected,
or None if no cropping needed or detection failed.
"""
try:
# First, get source video dimensions to check if it's 16:9
dim_cmd = [
"ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0", video_path
]
print(f" $ {shlex.join(dim_cmd)}")
dim_result = subprocess.run(dim_cmd, capture_output=True, text=True, timeout=30)
if dim_result.returncode != 0:
print(f" Failed to get dimensions: {dim_result.stderr.strip()}")
return None
# Parse "width,height" output
parts = dim_result.stdout.strip().split(",")
if len(parts) < 2:
return None
src_width, src_height = int(parts[0]), int(parts[1])
# Check if source is 16:9 (allow small tolerance for weird resolutions)
# 16:9 = 1.777..., typical: 1920x1080, 3840x2160, 1280x720
aspect_ratio = src_width / src_height
if not (1.7 <= aspect_ratio <= 1.85):
# Not 16:9, skip crop detection (already correctly framed)
return None
# Use ffmpeg to run cropdetect on just 2 seconds at 5-minute mark
# This is much faster than scanning 60 seconds with ffprobe lavfi
cmd = [
"ffmpeg", "-hide_banner", "-ss", "300", "-i", video_path,
"-t", "2", "-vf", "cropdetect=limit=24:round=2:reset=0",
"-f", "null", "-"
]
print(f" $ {shlex.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
# cropdetect outputs to stderr like: [Parsed_cropdetect_0 @ ...] x1:0 x2:1919 y1:138 y2:941 w:1920 h:800 ...
# We need to parse the crop values from stderr
crop_pattern = re.compile(r'crop=(\d+):(\d+):(\d+):(\d+)')
crop_values = []
for line in result.stderr.split('\n'):
match = crop_pattern.search(line)
if match:
w, h, x, y = int(match.group(1)), int(match.group(2)), int(match.group(3)), int(match.group(4))
if w > 0 and h > 0 and x >= 0 and y >= 0:
crop_values.append((w, h, x, y))
if not crop_values:
return None
# Use the most common crop values (mode) for stability
most_common = Counter(crop_values).most_common(1)
if not most_common:
return None
w, h, x, y = most_common[0][0]
# Only crop if there's meaningful black bar removal (at least 8 pixels offset)
if x < 8 and y < 8:
return None
# Validate symmetry: trust only if cropping is symmetric in one direction
# (same top/bottom for letterbox, OR same left/right for pillarbox)
# Allow up to 4 pixels of rounding error
left_crop = x
right_crop = src_width - (x + w)
top_crop = y
bottom_crop = src_height - (y + h)
horizontal_symmetric = abs(left_crop - right_crop) <= 4
vertical_symmetric = abs(top_crop - bottom_crop) <= 4
# Must be symmetric in at least one direction, but not require both
# (letterbox = vertical symmetric, pillarbox = horizontal symmetric)
if not (horizontal_symmetric or vertical_symmetric):
return None
# If cropping in both directions, both must be symmetric
if x >= 8 and y >= 8:
if not (horizontal_symmetric and vertical_symmetric):
return None
# Align all coordinates to 8 pixels (shrink content area if needed)
# x and y: round UP to next multiple of 8
x_aligned = ((x + 7) // 8) * 8
y_aligned = ((y + 7) // 8) * 8
# w and h: round DOWN to multiple of 8, accounting for adjusted x/y
w_aligned = ((w - (x_aligned - x)) // 8) * 8
h_aligned = ((h - (y_aligned - y)) // 8) * 8
# Ensure we still have valid dimensions
if w_aligned <= 0 or h_aligned <= 0:
return None
crop_result = f"crop={w_aligned}:{h_aligned}:{x_aligned}:{y_aligned}"
print(f" Detected crop: {crop_result}")
return crop_result
except Exception as e:
print(f" Crop detection error: {e}")
return None
def get_video_duration(video_path: str) -> Optional[float]:
"""
Get the duration of a video file in seconds using ffprobe.
"""
try:
result = subprocess.run(
[
"ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "json", video_path
],
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
return None
data = json.loads(result.stdout)
duration = data.get("format", {}).get("duration")
return float(duration) if duration else None
except Exception:
return None
def generate_showreel_images(
video_path: str,
media_folder: Path,
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
title: str = None,
pbar: Optional[tqdm] = None,
) -> list[str]:
"""
Generate showreel video clips from a video file at specified timestamps.
Saves 10-second clips in WebM format (AV1 video + Opus 2.0 audio), downscaled to max 720px width,
preserving original color metadata. Files are named reel1.webm, reel2.webm, etc.
Args:
video_path: Path to the video file (or index.bdmv for Blu-ray discs)
media_folder: Folder for this specific media item
timestamps: List of timestamps in seconds to capture
pbar: Optional tqdm progress bar to update
Returns:
List of relative paths to generated showreel video clips
"""
if not video_path:
return []
# Handle Blu-ray disc structures using bluray: protocol
bluray_uri = get_bluray_uri(video_path)
if bluray_uri:
ffmpeg_input = bluray_uri
else:
if not Path(video_path).exists():
return []
ffmpeg_input = video_path
# Fast path: check if all showreel clips already exist before any ffprobe calls
existing_paths = []
all_exist = True
for reel_num in range(1, len(timestamps) + 1):
output_filename = f"reel{reel_num}.webm"
output_path = media_folder / output_filename
if output_path.exists():
existing_paths.append(str(output_path))
else:
all_exist = False
break
if all_exist and existing_paths:
return existing_paths
media_folder.mkdir(parents=True, exist_ok=True)
# Check video duration to avoid seeking past the end
duration = get_video_duration(ffmpeg_input)
if duration is None:
print(f" Could not get duration for: {video_path}")
return []
# Filter timestamps that are within the video duration (with 40s margin for 10s clips)
valid_timestamps = [t for t in timestamps if t < (duration - 40)]
if not valid_timestamps:
# If video is too short, try to get at least one clip from middle
if duration > 60:
valid_timestamps = [int(duration / 2) - 5] # Center the 10s clip
else:
return []
# Get the best available AV1 encoder
encoder = get_av1_encoder()
encoder_opts = get_encoder_options(encoder)
# Detect Dolby Vision profile for tonemapping (profiles 5/7 need conversion)
dovi_profile = detect_dovi_profile(ffmpeg_input)
needs_tonemap = dovi_profile is not None and dovi_profile in (5, 7)
if needs_tonemap:
print(f" DoVi profile {dovi_profile} detected, will convert to HDR10")
# Detect black bars once for all clips (uses same video source)
crop_filter = detect_crop(ffmpeg_input)
generated_paths = []
for reel_num, timestamp in enumerate(valid_timestamps, 1):
output_filename = f"reel{reel_num}.webm"
output_path = media_folder / output_filename
# Skip if already exists
if output_path.exists():
generated_paths.append(str(output_path))
if pbar:
pbar.update(1)
continue
# Build video filter chain:
# 1. DoVi to HDR10 conversion (if needed) - must come first
# 2. Crop black bars (if detected)
# 3. Scale to max 720px width
vf_parts = []
if needs_tonemap:
vf_parts.append(get_dovi_to_hdr10_filter())
if crop_filter:
vf_parts.append(crop_filter)
vf_parts.append("scale='min(720,iw)':-2")
vf_filter = ",".join(vf_parts)
cmd = [
"ffmpeg", "-y", "-ss", str(timestamp), "-i", ffmpeg_input,
"-hide_banner", "-loglevel", "warning", "-stats",
"-map", "0:v:0", "-map", "0:a:0?", # First video, first audio (optional)
"-t", "10",
"-vf", vf_filter,
"-c:v", encoder,
*encoder_opts,
"-c:a", "libopus",
"-ac", "2",
"-b:a", "128k",
str(output_path),
]
print(f" $ {shlex.join(cmd)}")
try:
result = subprocess.run(cmd, timeout=120)
if result.returncode == 0 and output_path.exists():
generated_paths.append(str(output_path))
if pbar:
pbar.update(1)
else:
output_path.unlink(missing_ok=True)
if pbar:
# Update remaining reels as skipped
remaining = len(valid_timestamps) - reel_num + 1
pbar.update(remaining)
pbar.refresh()
# Abort remaining reels - if first one fails, others likely will too
break
except BaseException as e:
output_path.unlink(missing_ok=True)
if isinstance(e, (KeyboardInterrupt, SystemExit)):
raise
if pbar:
pbar.clear()
print(f"\n\033[91mError generating showreel for {title or 'unknown'} at {timestamp}s: {e}\033[0m")
if pbar:
# Update remaining reels as skipped
remaining = len(valid_timestamps) - reel_num + 1
pbar.update(remaining)
pbar.refresh()
# Abort remaining reels
break
return generated_paths
def generate_episode_reel(
video_path: str,
media_folder: Path,
season_num: int,
episode_num: int,
pbar: Optional[tqdm] = None,
) -> Optional[str]:
"""
Generate a single 10-second reel video clip for a TV episode.
Saves clip as SxxExx.webm (e.g., S01E05.webm) in the series folder.
WebM container with AV1 video + Opus 2.0 audio, downscaled to max 720px width,
preserving original color metadata.
Args:
video_path: Path to the episode video file (or index.bdmv for Blu-ray discs)
media_folder: Folder for this series
season_num: Season number
episode_num: Episode number
pbar: Optional tqdm progress bar to update
Returns:
Relative path to generated image, or None if failed
"""
if not video_path:
return None
# Handle Blu-ray disc structures using bluray: protocol
bluray_uri = get_bluray_uri(video_path)
if bluray_uri:
ffmpeg_input = bluray_uri
else:
if not Path(video_path).exists():
return None
ffmpeg_input = video_path
media_folder.mkdir(parents=True, exist_ok=True)
# Normalize episode code to SxxExx format
output_filename = f"S{season_num:02d}E{episode_num:02d}.webm"
output_path = media_folder / output_filename
# Skip if already exists
if output_path.exists():
return str(output_path)
# Check video duration
duration = get_video_duration(ffmpeg_input)
if duration is None:
return None
# Use 40% of total length for the clip start
actual_timestamp = int(duration * 0.4)
# Ensure we're at least 10 seconds in and have room for 10s clip
actual_timestamp = max(10, min(actual_timestamp, duration - 40))
# Get the best available AV1 encoder
encoder = get_av1_encoder()
encoder_opts = get_encoder_options(encoder)
# Detect Dolby Vision profile for tonemapping (profiles 5/7 need conversion)
dovi_profile = detect_dovi_profile(ffmpeg_input)
needs_tonemap = dovi_profile is not None and dovi_profile in (5, 7)
if needs_tonemap:
print(f" DoVi profile {dovi_profile} detected, will convert to HDR10")
# Detect black bars for cropping
crop_filter = detect_crop(ffmpeg_input)
# Build video filter chain:
# 1. DoVi to HDR10 conversion (if needed) - must come first
# 2. Crop black bars (if detected)
# 3. Scale to max 720px width
vf_parts = []
if needs_tonemap:
vf_parts.append(get_dovi_to_hdr10_filter())
if crop_filter:
vf_parts.append(crop_filter)
vf_parts.append("scale='min(720,iw)':-2")
vf_filter = ",".join(vf_parts)
cmd = [
"ffmpeg", "-y", "-ss", str(actual_timestamp), "-i", ffmpeg_input,
"-hide_banner", "-loglevel", "warning", "-stats",
"-map", "0:v:0", "-map", "0:a:0?", # First video, first audio (optional)
"-t", "10",
"-vf", vf_filter,
"-c:v", encoder,
*encoder_opts,
"-c:a", "libopus",
"-ac", "2",
"-b:a", "128k",
str(output_path),
]
print(f" $ {shlex.join(cmd)}")
try:
result = subprocess.run(cmd, timeout=120)
if result.returncode == 0 and output_path.exists():
if pbar:
pbar.update(1)
return str(output_path)
else:
output_path.unlink(missing_ok=True)
if pbar:
pbar.update(1)
pbar.refresh()
return None
except BaseException as e:
output_path.unlink(missing_ok=True)
if isinstance(e, (KeyboardInterrupt, SystemExit)):
raise
if pbar:
pbar.clear()
episode_code = f"S{season_num:02d}E{episode_num:02d}"
print(f"\n\033[91mError generating episode reel for {episode_code}: {e}\033[0m")
if pbar:
pbar.update(1)
pbar.refresh()
return None