Files
2026-03-22 03:21:39 +01:00

185 lines
6.3 KiB
Python

import subprocess
import tempfile
import os
from pathlib import Path
from typing import List, Tuple, Optional
import numpy as np
from scipy.io import wavfile
class ConfidenceError(Exception):
"""Raised when an area is found but the energy difference is too low to be confident."""
pass
class MissingDetectionError(Exception):
"""Raised when the script fails to find both an intro and an outro."""
pass
def get_video_duration(file: Path) -> float:
"""Uses ffprobe to get the total duration of the video."""
cmd = [
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(file)
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
return float(result.stdout.strip())
except ValueError:
raise RuntimeError(f"Could not determine duration for {file}")
def extract_audio_segment_tmp(
file: Path,
start_or_section: float | tuple[float, float],
end: Optional[float] = None,
audio_stream: Optional[int] = None
) -> Path:
"""Extracts audio using FFmpeg and saves it to a temporary WAV file."""
if isinstance(start_or_section, tuple):
if len(start_or_section) != 2:
raise ValueError("Tuple must strictly be (start, end)")
start = float(start_or_section[0])
duration = float(start_or_section[1])
elif isinstance(start_or_section, (int, float)):
if end is None:
raise ValueError("The 'end' parameter must be provided when using a separate start float.")
start = float(start_or_section)
end_time = float(end)
duration = end_time - start
else:
raise TypeError("Invalid type for start_or_section. Expected float or tuple.")
if duration <= 0:
raise ValueError("Duration must be above 0")
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp_path = Path(tmp.name)
tmp.close()
cmd = [
"ffmpeg", "-y",
"-i", str(file),
"-ss", str(start),
"-t", str(duration)
]
if audio_stream is not None:
cmd += ["-map", f"0:a:{audio_stream}"]
else:
cmd += ["-map", "0:a:0"]
cmd += [
"-vn",
"-acodec", "pcm_s16le",
"-ar", "44100",
"-ac", "2",
str(tmp_path)
]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
return tmp_path
def find_high_power_segment(wav_path: Path, expected_length: float, confidence_threshold: float = 1.0) -> float:
rate, data = wavfile.read(wav_path)
if len(data.shape) > 1:
data = data.mean(axis=1)
data = data.astype(np.float32) / (np.max(np.abs(data)) + 1e-9)
chunk_size = rate
num_chunks = len(data) // chunk_size
# Calculate RMS (Volume) and Standard Deviation (Variation) for every second
powers = np.zeros(num_chunks)
variations = np.zeros(num_chunks)
for i in range(num_chunks):
chunk = data[i * chunk_size : (i + 1) * chunk_size]
powers[i] = np.sqrt(np.mean(chunk**2))
variations[i] = np.std(chunk)
window_size = int(expected_length)
if len(powers) < window_size:
raise ValueError("Audio segment too short.")
# SCORING ALGORITHM:
# We want High Power and Low Variation (that "solid block" look)
# We invert variation so that a small variation results in a higher score.
scores = []
for i in range(len(powers) - window_size + 1):
win_power = np.mean(powers[i : i + window_size])
win_var = np.mean(variations[i : i + window_size])
# The score increases with power and decreases as the variation increases
# We add a small constant to win_var to avoid division by zero
score = win_power / (win_var + 0.05)
scores.append(score)
best_idx = np.argmax(scores)
# Confidence check: is our best score significantly better than the average score?
avg_score = np.mean(scores)
if scores[best_idx] < avg_score * confidence_threshold:
raise ConfidenceError("Detection unreliable: no distinct high-power constant block found.")
return float(best_idx)
def get_intro_outro(file: Path, length: float = 90.0) -> List[Tuple[float, float]]:
"""
Finds the intro and outro of a video file by analyzing audio power peaks.
Searches the first 8 minutes and the last 8 minutes.
"""
results = []
total_duration = get_video_duration(file)
search_window = 8 * 60.0 # 8 minutes
# --- 1. FIND INTRO ---
intro_search_dur = min(search_window, total_duration)
intro_wav = extract_audio_segment_tmp(file, 0.0, intro_search_dur)
try:
intro_start_rel = find_high_power_segment(intro_wav, length)
results.append((intro_start_rel, length))
except ConfidenceError:
intro_wav.unlink()
raise ConfidenceError("Could not confidently detect the Intro.")
finally:
if intro_wav.exists():
intro_wav.unlink() # Cleanup temp file
# --- 2. FIND OUTRO ---
outro_search_start = max(0.0, total_duration - search_window)
outro_search_dur = total_duration - outro_search_start
outro_wav = extract_audio_segment_tmp(file, outro_search_start, total_duration)
try:
outro_start_rel = find_high_power_segment(outro_wav, length)
# Add the 8-minute offset back to get the absolute timestamp in the video
outro_start_abs = outro_search_start + outro_start_rel
results.append((outro_start_abs, length))
except ConfidenceError:
outro_wav.unlink()
raise ConfidenceError("Could not confidently detect the Outro.")
finally:
if outro_wav.exists():
outro_wav.unlink() # Cleanup temp file
# Ensure we got both
if len(results) < 2:
raise MissingDetectionError("Failed to find both an intro and an outro.")
return results
# --- Example Usage ---
if __name__ == "__main__":
try:
# Requires: pip install numpy scipy
file_path = Path("/home/honney/mount/Media/tmp/serien/ready/Charlotte/Season 01/[NO CHAPTER]_E04.mkv")
if file_path.exists():
timestamps = get_intro_outro(file_path, length=90.0)
print("Found Intro and Outro [Start, Duration]:", timestamps)
except Exception as e:
print(f"Error: {e}")