Added tests and more
This commit is contained in:
1 parent
295177be6f
commit
a7a7abf720
9 files changed
+1327
No files matched your search
@@ -0,0 +1,185 @@
|
||||
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}")
|
||||
Reference in new issue
Block a user