1034 lines
33 KiB
Python
1034 lines
33 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 asyncio
|
|
import contextlib
|
|
import logging
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
from collections import Counter
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from aiopathlib import AsyncPath
|
|
|
|
from mediahive.hivescan.utils import classify_resolution_from_dimensions
|
|
|
|
logger = logging.getLogger("hivescan.showreel")
|
|
_ffmpeg_not_found_logged = False
|
|
|
|
|
|
# Suppress console windows when spawning subprocesses on Windows
|
|
async def _subprocess_exec(*args: str, **kwargs: Any):
|
|
"""Wrap asyncio.create_subprocess_exec to hide console windows on Windows."""
|
|
if sys.platform == "win32":
|
|
kwargs.setdefault("creationflags", subprocess.CREATE_NO_WINDOW)
|
|
return await asyncio.create_subprocess_exec(*args, **kwargs)
|
|
|
|
|
|
def _decode_stderr(stderr: bytes | None) -> str:
|
|
if not stderr:
|
|
return "(no stderr output)"
|
|
text = stderr.decode("utf-8", errors="replace").strip()
|
|
return text[:1500] if text else "(no stderr output)"
|
|
|
|
|
|
def _log_ffmpeg_not_found_once(cmd: list[str]) -> None:
|
|
global _ffmpeg_not_found_logged
|
|
if _ffmpeg_not_found_logged:
|
|
return
|
|
_ffmpeg_not_found_logged = True
|
|
logger.error(
|
|
"ffmpeg executable was not found on PATH. "
|
|
"Install ffmpeg and restart MediaHive. Command: %s",
|
|
shlex.join(cmd),
|
|
)
|
|
|
|
|
|
async def _run_ffmpeg(
|
|
cmd: list[str],
|
|
timeout_seconds: float,
|
|
allow_nonzero_exit: bool = False,
|
|
) -> tuple[bytes, bytes] | None:
|
|
"""Run ffmpeg with consistent timeout/crash/not-found handling and logging.
|
|
|
|
Set ``allow_nonzero_exit`` for probe-style commands where ffmpeg may return
|
|
a non-zero code while still emitting useful metadata on stderr.
|
|
"""
|
|
proc: asyncio.subprocess.Process | None = None
|
|
try:
|
|
logger.debug(" $ %s", shlex.join(cmd))
|
|
proc = await _subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(
|
|
proc.communicate(), timeout=timeout_seconds
|
|
)
|
|
except TimeoutError:
|
|
await _kill_proc(proc)
|
|
try:
|
|
stdout, stderr = await proc.communicate()
|
|
except OSError, subprocess.SubprocessError:
|
|
stdout, stderr = b"", b""
|
|
logger.exception(
|
|
"ffmpeg command timed out. cmd=%s stderr=%s",
|
|
shlex.join(cmd),
|
|
_decode_stderr(stderr),
|
|
)
|
|
return None
|
|
|
|
if proc.returncode != 0:
|
|
if allow_nonzero_exit:
|
|
logger.debug(
|
|
"ffmpeg command exited non-zero as expected for probe. "
|
|
"cmd=%s returncode=%s",
|
|
shlex.join(cmd),
|
|
proc.returncode,
|
|
)
|
|
return stdout, stderr
|
|
logger.error(
|
|
"ffmpeg command failed. cmd=%s stderr=%s",
|
|
shlex.join(cmd),
|
|
_decode_stderr(stderr),
|
|
)
|
|
return None
|
|
|
|
return stdout, stderr
|
|
except FileNotFoundError:
|
|
_log_ffmpeg_not_found_once(cmd)
|
|
return None
|
|
except asyncio.CancelledError:
|
|
await _kill_proc(proc)
|
|
raise
|
|
except OSError, subprocess.SubprocessError:
|
|
logger.exception("Unexpected error running ffmpeg command: %s", shlex.join(cmd))
|
|
return None
|
|
|
|
|
|
async def _kill_proc(proc: asyncio.subprocess.Process | None) -> None:
|
|
"""Kill a subprocess immediately if it is still running."""
|
|
if proc is not None and proc.returncode is None:
|
|
proc.kill()
|
|
with contextlib.suppress(Exception):
|
|
await asyncio.wait_for(proc.wait(), timeout=0.5)
|
|
|
|
|
|
# Showreel timestamp positions in seconds (5, 10, 15, 20, 25 minutes)
|
|
SHOWREEL_TIMESTAMPS = [5 * 60, 10 * 60, 15 * 60, 20 * 60, 25 * 60]
|
|
REEL_SOURCE_EXTENSIONS = [".webm", ".mp4"]
|
|
# Temporary rollout switch: keep platform-native MP4/H.265 path available but disabled.
|
|
ENABLE_PLATFORM_NATIVE_REELS = False
|
|
|
|
|
|
def get_reel_source_extensions() -> list[str]:
|
|
"""Return reel source extensions in client preference order."""
|
|
return REEL_SOURCE_EXTENSIONS.copy()
|
|
|
|
|
|
def _to_media_path(path: Path, media_root: Path | None = None) -> str:
|
|
"""Convert an absolute reel path to media-root-relative when possible."""
|
|
if media_root:
|
|
try:
|
|
return path.relative_to(media_root).as_posix()
|
|
except ValueError:
|
|
return path.as_posix()
|
|
return path.as_posix()
|
|
|
|
|
|
def get_reel_extension() -> str:
|
|
"""Return the platform-native reel file extension."""
|
|
if not ENABLE_PLATFORM_NATIVE_REELS:
|
|
return ".webm"
|
|
return ".mp4" if sys.platform == "darwin" else ".webm"
|
|
|
|
|
|
async def get_reel_video_encoder() -> str:
|
|
"""Return the platform-native reel video encoder."""
|
|
if not ENABLE_PLATFORM_NATIVE_REELS:
|
|
return await get_av1_encoder()
|
|
if sys.platform == "darwin":
|
|
return "libx265"
|
|
return await get_av1_encoder()
|
|
|
|
|
|
def get_reel_video_options(encoder: str) -> list[str]:
|
|
"""Return ffmpeg video encoder options for the chosen reel encoder."""
|
|
if encoder == "libx265":
|
|
return ["-crf", "28", "-preset", "medium", "-tag:v", "hvc1"]
|
|
if encoder == "av1_nvenc":
|
|
return ["-cq", "35", "-preset", "p4"]
|
|
return ["-crf", "38", "-preset", "6"]
|
|
|
|
|
|
def get_reel_audio_options() -> list[str]:
|
|
"""Return ffmpeg audio and container options for the current platform."""
|
|
if not ENABLE_PLATFORM_NATIVE_REELS:
|
|
return ["-c:a", "libopus", "-ac", "2", "-b:a", "128k"]
|
|
if sys.platform == "darwin":
|
|
return ["-c:a", "aac", "-ac", "2", "-b:a", "128k", "-movflags", "+faststart"]
|
|
return ["-c:a", "libopus", "-ac", "2", "-b:a", "128k"]
|
|
|
|
|
|
def get_expected_showreel_paths(
|
|
media_folder: Path,
|
|
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
|
|
media_root: Path | None = 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 for this platform
|
|
|
|
"""
|
|
paths = []
|
|
extension = get_reel_extension()
|
|
for reel_num in range(1, len(timestamps) + 1):
|
|
output_path = media_folder / f"reel{reel_num}{extension}"
|
|
if media_root:
|
|
try:
|
|
paths.append(output_path.relative_to(media_root).as_posix())
|
|
except ValueError:
|
|
paths.append(output_path.as_posix())
|
|
else:
|
|
paths.append(output_path.as_posix())
|
|
return paths
|
|
|
|
|
|
def get_expected_episode_reel_path(
|
|
media_folder: Path,
|
|
season_num: int,
|
|
episode_num: int,
|
|
media_root: Path | None = 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 for this platform
|
|
|
|
"""
|
|
output_path = (
|
|
media_folder / f"S{season_num:02d}E{episode_num:02d}{get_reel_extension()}"
|
|
)
|
|
if media_root:
|
|
try:
|
|
return output_path.relative_to(media_root).as_posix()
|
|
except ValueError:
|
|
return output_path.as_posix()
|
|
return output_path.as_posix()
|
|
|
|
|
|
def get_existing_showreel_paths(
|
|
media_folder: Path,
|
|
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
|
|
media_root: Path | None = None,
|
|
) -> list[str]:
|
|
"""Return preferred showreel paths, one per reel slot, in AV1-first order."""
|
|
source_sets = get_existing_showreel_source_sets(
|
|
media_folder,
|
|
timestamps=timestamps,
|
|
media_root=media_root,
|
|
)
|
|
return [sources[0] for sources in source_sets if sources]
|
|
|
|
|
|
def get_existing_showreel_source_sets(
|
|
media_folder: Path,
|
|
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
|
|
media_root: Path | None = None,
|
|
) -> list[list[str]]:
|
|
"""Return all showreel source files per reel slot in AV1-first order."""
|
|
source_sets: list[list[str]] = []
|
|
for reel_num in range(1, len(timestamps) + 1):
|
|
sources = [
|
|
_to_media_path(media_folder / f"reel{reel_num}{extension}", media_root)
|
|
for extension in get_reel_source_extensions()
|
|
if (media_folder / f"reel{reel_num}{extension}").exists()
|
|
]
|
|
if sources:
|
|
source_sets.append(sources)
|
|
return source_sets
|
|
|
|
|
|
def get_existing_episode_reel_path(
|
|
media_folder: Path,
|
|
season_num: int,
|
|
episode_num: int,
|
|
media_root: Path | None = None,
|
|
) -> str | None:
|
|
"""Return the preferred existing episode reel path in AV1-first order."""
|
|
sources = get_existing_episode_reel_sources(
|
|
media_folder,
|
|
season_num,
|
|
episode_num,
|
|
media_root=media_root,
|
|
)
|
|
return sources[0] if sources else None
|
|
|
|
|
|
def get_existing_episode_reel_sources(
|
|
media_folder: Path,
|
|
season_num: int,
|
|
episode_num: int,
|
|
media_root: Path | None = None,
|
|
) -> list[str]:
|
|
"""Return all existing episode reel source files in AV1-first order."""
|
|
ep_code = f"S{season_num:02d}E{episode_num:02d}"
|
|
return [
|
|
_to_media_path(media_folder / f"{ep_code}{extension}", media_root)
|
|
for extension in get_reel_source_extensions()
|
|
if (media_folder / f"{ep_code}{extension}").exists()
|
|
]
|
|
|
|
|
|
async def movie_showreels_exist(
|
|
media_folder: Path, timestamps: list[int] = SHOWREEL_TIMESTAMPS
|
|
) -> bool:
|
|
"""Check if all platform-native showreel files for a movie already exist."""
|
|
extension = get_reel_extension()
|
|
for reel_num in range(1, len(timestamps) + 1):
|
|
if not await AsyncPath(media_folder / f"reel{reel_num}{extension}").exists():
|
|
return False
|
|
return True
|
|
|
|
|
|
async def episode_reel_exists(
|
|
media_folder: Path, season_num: int, episode_num: int
|
|
) -> bool:
|
|
"""Check if a platform-native episode reel file already exists."""
|
|
return await AsyncPath(
|
|
media_folder / f"S{season_num:02d}E{episode_num:02d}{get_reel_extension()}"
|
|
).exists()
|
|
|
|
|
|
def get_bluray_uri(video_path: str) -> str | None:
|
|
"""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.as_posix()}"
|
|
|
|
return None
|
|
|
|
|
|
# Cache for AV1 encoder availability
|
|
_av1_encoder_cache: str | None = None
|
|
|
|
|
|
async 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
|
|
encoders = await _run_ffmpeg(
|
|
["ffmpeg", "-hide_banner", "-encoders"], timeout_seconds=10
|
|
)
|
|
if encoders and b"av1_nvenc" in encoders[0]:
|
|
# Verify it actually works (driver support)
|
|
test_run = await _run_ffmpeg(
|
|
[
|
|
"ffmpeg",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"nullsrc=s=64x64:d=1",
|
|
"-c:v",
|
|
"av1_nvenc",
|
|
"-f",
|
|
"null",
|
|
"-",
|
|
],
|
|
timeout_seconds=10,
|
|
)
|
|
if test_run is not None:
|
|
_av1_encoder_cache = "av1_nvenc"
|
|
return _av1_encoder_cache
|
|
|
|
# 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"]
|
|
# libsvtav1 software encoder
|
|
return ["-crf", "38", "-preset", "6"]
|
|
|
|
|
|
@dataclass
|
|
class MediaProbeInfo:
|
|
duration: float | None = None
|
|
width: int | None = None
|
|
height: int | None = None
|
|
hdr: bool = False
|
|
dovi_profile: int | None = None
|
|
dovi: bool = False
|
|
atmos: bool = False
|
|
hdr10plus: bool = False
|
|
resolution: str | None = None
|
|
audio_languages: list[str] | None = None
|
|
subtitle_languages: list[str] | None = None
|
|
|
|
|
|
_media_probe_cache: dict[str, MediaProbeInfo] = {}
|
|
_duration_re = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
|
|
_dimension_re = re.compile(r"(\d{2,5})x(\d{2,5})")
|
|
_dovi_profile_re = re.compile(
|
|
r"DOVI configuration record:.*?profile:\s*(\d+)", re.IGNORECASE
|
|
)
|
|
_audio_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Audio:")
|
|
_subtitle_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Subtitle:")
|
|
# showinfo emits e.g. "side data - HDR Dynamic Metadata SMPTE2094-40 (HDR10+)"
|
|
_showinfo_hdr10plus_re = re.compile(r"SMPTE2094-40|HDR Dynamic Metadata", re.IGNORECASE)
|
|
|
|
|
|
def _lang_code(raw: str | None) -> str | None:
|
|
if not raw:
|
|
return None
|
|
code = raw.strip().split(",", 1)[0].lower()
|
|
return code if code and code != "und" else None
|
|
|
|
|
|
async def probe_media_info(video_path: str) -> MediaProbeInfo:
|
|
"""Parse key media metadata from `ffmpeg -i` output."""
|
|
cached = _media_probe_cache.get(video_path)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
info = MediaProbeInfo()
|
|
cmd = ["ffmpeg", "-hide_banner", "-i", video_path]
|
|
ffmpeg_result = await _run_ffmpeg(cmd, timeout_seconds=30, allow_nonzero_exit=True)
|
|
if ffmpeg_result is None:
|
|
_media_probe_cache[video_path] = info
|
|
return info
|
|
|
|
stdout, stderr = ffmpeg_result
|
|
text = (stderr + stdout).decode("utf-8", errors="replace")
|
|
lower_text = text.lower()
|
|
|
|
duration_match = _duration_re.search(text)
|
|
if duration_match:
|
|
hours = int(duration_match.group(1))
|
|
minutes = int(duration_match.group(2))
|
|
seconds = float(duration_match.group(3))
|
|
info.duration = hours * 3600 + minutes * 60 + seconds
|
|
|
|
video_line = None
|
|
for line in text.splitlines():
|
|
if "Stream #" in line and "Video:" in line:
|
|
video_line = line
|
|
break
|
|
if video_line:
|
|
dim_match = _dimension_re.search(video_line)
|
|
if dim_match:
|
|
info.width = int(dim_match.group(1))
|
|
info.height = int(dim_match.group(2))
|
|
info.resolution = classify_resolution_from_dimensions(
|
|
info.width, info.height
|
|
)
|
|
|
|
info.hdr = (
|
|
"smpte2084" in lower_text
|
|
or "arib-std-b67" in lower_text
|
|
or "bt2020" in lower_text
|
|
)
|
|
info.hdr10plus = (
|
|
"hdr10+" in lower_text
|
|
or "hdr10plus" in lower_text
|
|
or "smpte st 2094" in lower_text
|
|
or "smpte-st-2094" in lower_text
|
|
or "dynamic hdr" in lower_text
|
|
)
|
|
|
|
# `ffmpeg -i` only reads container headers, not frame-level side data.
|
|
# Run a tiny showinfo decode (5 frames) to detect HDR10+ dynamic metadata
|
|
# (SMPTE ST 2094-40) when the header scan didn't already confirm it.
|
|
if info.hdr and not info.hdr10plus and not info.dovi:
|
|
showinfo_cmd = [
|
|
"ffmpeg",
|
|
"-hide_banner",
|
|
"-ss",
|
|
"0",
|
|
"-i",
|
|
video_path,
|
|
"-vf",
|
|
"showinfo",
|
|
"-frames:v",
|
|
"5",
|
|
"-an",
|
|
"-f",
|
|
"null",
|
|
"-",
|
|
]
|
|
showinfo_result = await _run_ffmpeg(
|
|
showinfo_cmd, timeout_seconds=15, allow_nonzero_exit=True
|
|
)
|
|
if showinfo_result is not None:
|
|
si_stdout, si_stderr = showinfo_result
|
|
si_text = (si_stderr + si_stdout).decode("utf-8", errors="replace")
|
|
if _showinfo_hdr10plus_re.search(si_text):
|
|
info.hdr10plus = True
|
|
|
|
dovi_match = _dovi_profile_re.search(text)
|
|
if dovi_match:
|
|
info.dovi_profile = int(dovi_match.group(1))
|
|
elif "dvhe" in lower_text or "dvh1" in lower_text or "dav1" in lower_text:
|
|
info.dovi_profile = 7
|
|
info.dovi = info.dovi_profile is not None
|
|
|
|
audio_languages: list[str] = []
|
|
for line in text.splitlines():
|
|
if "Stream #" not in line or "Audio:" not in line:
|
|
continue
|
|
match = _audio_stream_re.search(line)
|
|
if not match:
|
|
continue
|
|
lang = _lang_code(match.group(1))
|
|
if lang and lang not in audio_languages:
|
|
audio_languages.append(lang)
|
|
if "atmos" in line.lower():
|
|
info.atmos = True
|
|
info.audio_languages = audio_languages or None
|
|
|
|
subtitle_languages: list[str] = []
|
|
for match in _subtitle_stream_re.finditer(text):
|
|
lang = _lang_code(match.group(1))
|
|
if lang and lang not in subtitle_languages:
|
|
subtitle_languages.append(lang)
|
|
info.subtitle_languages = subtitle_languages or None
|
|
|
|
_media_probe_cache[video_path] = info
|
|
return info
|
|
|
|
|
|
async def detect_dovi_profile(video_path: str) -> int | None:
|
|
"""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:
|
|
return (await probe_media_info(video_path)).dovi_profile
|
|
except (OSError, ValueError, RuntimeError) as e:
|
|
logger.warning(" DoVi detection error: %s", 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"
|
|
)
|
|
|
|
|
|
async def is_hdr_video(video_path: str) -> bool:
|
|
"""Check if a video file is HDR using ffmpeg probe output.
|
|
|
|
Returns True if the video has HDR metadata (bt2020, SMPTE ST 2084, etc.)
|
|
"""
|
|
try:
|
|
return (await probe_media_info(video_path)).hdr
|
|
except OSError, ValueError, RuntimeError:
|
|
return False
|
|
|
|
|
|
async def detect_crop(video_path: str) -> str | None:
|
|
"""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.
|
|
|
|
"""
|
|
# First, get source video dimensions to check if it's 16:9
|
|
probe_info = await probe_media_info(video_path)
|
|
if not probe_info.width or not probe_info.height:
|
|
return None
|
|
src_width, src_height = probe_info.width, probe_info.height
|
|
|
|
# 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 a long window with lavfi analysis.
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-hide_banner",
|
|
"-ss",
|
|
"300",
|
|
"-i",
|
|
video_path,
|
|
"-t",
|
|
"2",
|
|
"-vf",
|
|
"cropdetect=limit=24:round=2:reset=0",
|
|
"-f",
|
|
"null",
|
|
"-",
|
|
]
|
|
ffmpeg_result = await _run_ffmpeg(cmd, timeout_seconds=60)
|
|
if ffmpeg_result is None:
|
|
return None
|
|
_, stderr_bytes = ffmpeg_result
|
|
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
|
|
|
|
# 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 stderr_text.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 and 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}"
|
|
logger.debug(" Detected crop: %s", crop_result)
|
|
return crop_result
|
|
|
|
|
|
async def get_video_duration(video_path: str) -> float | None:
|
|
"""Get the duration of a video file in seconds using ffmpeg probe output."""
|
|
try:
|
|
return (await probe_media_info(video_path)).duration
|
|
except OSError, ValueError, RuntimeError:
|
|
return None
|
|
|
|
|
|
async def generate_showreel_images(
|
|
video_path: str,
|
|
media_folder: Path,
|
|
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
|
|
title: str | None = None,
|
|
on_progress=None,
|
|
) -> list[str]:
|
|
"""Generate showreel video clips from a video file at specified timestamps.
|
|
|
|
Saves 10-second clips in a platform-native format, downscaled to max 720px width,
|
|
preserving original color metadata.
|
|
macOS emits MP4/H.265; other platforms emit WebM/AV1.
|
|
|
|
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
|
|
title: Title for logging
|
|
on_progress: Optional callback(reel_num) called after each reel completes
|
|
|
|
Returns:
|
|
List of relative paths to generated showreel video clips
|
|
|
|
"""
|
|
if not video_path:
|
|
logger.warning(
|
|
" Showreel: no video path provided for %s", title or "unknown"
|
|
)
|
|
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 await AsyncPath(video_path).exists():
|
|
logger.warning(" Showreel: video file does not exist: %s", video_path)
|
|
return []
|
|
ffmpeg_input = video_path
|
|
|
|
# Fast path: check if all showreel clips already exist before any probe calls
|
|
existing_paths = []
|
|
all_exist = True
|
|
extension = get_reel_extension()
|
|
for reel_num in range(1, len(timestamps) + 1):
|
|
output_filename = f"reel{reel_num}{extension}"
|
|
output_path = media_folder / output_filename
|
|
if await AsyncPath(output_path).exists():
|
|
existing_paths.append(output_path.as_posix())
|
|
else:
|
|
all_exist = False
|
|
break
|
|
|
|
if all_exist and existing_paths:
|
|
return existing_paths
|
|
|
|
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Check video duration to avoid seeking past the end
|
|
duration = await get_video_duration(ffmpeg_input)
|
|
if duration is None:
|
|
logger.warning(" Could not get duration for: %s", 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:
|
|
logger.warning(
|
|
" Showreel: video too short (%.0fs) for %s: %s",
|
|
duration,
|
|
title or "unknown",
|
|
video_path,
|
|
)
|
|
return []
|
|
|
|
encoder = await get_reel_video_encoder()
|
|
encoder_opts = get_reel_video_options(encoder)
|
|
audio_opts = get_reel_audio_options()
|
|
|
|
# Detect Dolby Vision profile for tonemapping (profiles 5/7 need conversion)
|
|
dovi_profile = await detect_dovi_profile(ffmpeg_input)
|
|
needs_tonemap = dovi_profile is not None and dovi_profile in {5, 7}
|
|
if needs_tonemap:
|
|
logger.info(" DoVi profile %d detected, will convert to HDR10", dovi_profile)
|
|
|
|
# Detect black bars once for all clips (uses same video source)
|
|
crop_filter = await detect_crop(ffmpeg_input)
|
|
|
|
generated_paths = []
|
|
|
|
for reel_num, timestamp in enumerate(valid_timestamps, 1):
|
|
output_filename = f"reel{reel_num}{extension}"
|
|
output_path = media_folder / output_filename
|
|
|
|
# Skip if already exists
|
|
if await AsyncPath(output_path).exists():
|
|
generated_paths.append(output_path.as_posix())
|
|
if on_progress:
|
|
on_progress(reel_num)
|
|
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,
|
|
*audio_opts,
|
|
output_path.as_posix(),
|
|
]
|
|
|
|
ffmpeg_result = await _run_ffmpeg(cmd, timeout_seconds=120)
|
|
if ffmpeg_result is None:
|
|
await AsyncPath(output_path).unlink(missing_ok=True)
|
|
break
|
|
|
|
if await AsyncPath(output_path).exists():
|
|
generated_paths.append(output_path.as_posix())
|
|
logger.info(
|
|
" Showreel reel%d generated for %s",
|
|
reel_num,
|
|
title or "unknown",
|
|
)
|
|
if on_progress:
|
|
on_progress(reel_num)
|
|
else:
|
|
logger.error(
|
|
" Showreel reel%d failed for %s: "
|
|
"output file was not created. cmd=%s",
|
|
reel_num,
|
|
title or "unknown",
|
|
shlex.join(cmd),
|
|
)
|
|
await AsyncPath(output_path).unlink(missing_ok=True)
|
|
break
|
|
|
|
if generated_paths:
|
|
logger.info(
|
|
" Showreel complete for %s: %d/%d reels",
|
|
title or "unknown",
|
|
len(generated_paths),
|
|
len(valid_timestamps),
|
|
)
|
|
else:
|
|
logger.warning(" Showreel: no reels generated for %s", title or "unknown")
|
|
|
|
return generated_paths
|
|
|
|
|
|
async def generate_episode_reel(
|
|
video_path: str,
|
|
media_folder: Path,
|
|
season_num: int,
|
|
episode_num: int,
|
|
) -> str | None:
|
|
"""Generate a single 10-second reel video clip for a TV episode.
|
|
|
|
Saves a platform-native clip such as S01E05.mp4 on macOS or S01E05.webm elsewhere.
|
|
The clip is downscaled to max 720px width while 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
|
|
|
|
Returns:
|
|
Relative path to generated image, or None if failed
|
|
|
|
"""
|
|
ep_code = f"S{season_num:02d}E{episode_num:02d}"
|
|
if not video_path:
|
|
logger.warning(" Episode reel: no video path for %s", ep_code)
|
|
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 await AsyncPath(video_path).exists():
|
|
logger.warning(
|
|
" Episode reel: file not found for %s: %s", ep_code, video_path
|
|
)
|
|
return None
|
|
ffmpeg_input = video_path
|
|
|
|
await AsyncPath(media_folder).mkdir(parents=True, exist_ok=True)
|
|
|
|
extension = get_reel_extension()
|
|
output_filename = f"{ep_code}{extension}"
|
|
output_path = media_folder / output_filename
|
|
|
|
# Skip if already exists
|
|
if await AsyncPath(output_path).exists():
|
|
return output_path.as_posix()
|
|
|
|
# Check video duration
|
|
duration = await get_video_duration(ffmpeg_input)
|
|
if duration is None:
|
|
logger.warning(
|
|
" Episode reel: could not get duration for %s: %s", ep_code, video_path
|
|
)
|
|
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))
|
|
|
|
encoder = await get_reel_video_encoder()
|
|
encoder_opts = get_reel_video_options(encoder)
|
|
audio_opts = get_reel_audio_options()
|
|
|
|
# Detect Dolby Vision profile for tonemapping (profiles 5/7 need conversion)
|
|
dovi_profile = await detect_dovi_profile(ffmpeg_input)
|
|
needs_tonemap = dovi_profile is not None and dovi_profile in {5, 7}
|
|
if needs_tonemap:
|
|
logger.info(" DoVi profile %d detected, will convert to HDR10", dovi_profile)
|
|
|
|
# Detect black bars for cropping
|
|
crop_filter = await 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,
|
|
*audio_opts,
|
|
output_path.as_posix(),
|
|
]
|
|
|
|
ffmpeg_result = await _run_ffmpeg(cmd, timeout_seconds=120)
|
|
if ffmpeg_result is None:
|
|
await AsyncPath(output_path).unlink(missing_ok=True)
|
|
return None
|
|
|
|
if await AsyncPath(output_path).exists():
|
|
logger.info(" Episode reel generated: %s", ep_code)
|
|
return output_path.as_posix()
|
|
|
|
logger.error(
|
|
" Episode reel %s failed: output file was not created. cmd=%s",
|
|
ep_code,
|
|
shlex.join(cmd),
|
|
)
|
|
await AsyncPath(output_path).unlink(missing_ok=True)
|
|
return None
|