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

210 lines
5.4 KiB
Python

from pathlib import Path
from typing import Tuple, List, Optional
import json
import subprocess
import tempfile
import numpy as np
import soundfile as sf
from scipy.signal import correlate
def run(cmd):
"""
Run a subprocess command.
Args:
cmd: Command and arguments as a list.
Raises:
subprocess.CalledProcessError: If the command exits with a non-zero status.
"""
try:
result = subprocess.run(
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
return result
except subprocess.CalledProcessError as e:
print(e.stdout)
raise
def _get_chapters(file: Path) -> List[Tuple[float, float]]:
"""Return list of (start_time, duration) for each chapter."""
cmd = [
"ffprobe",
"-i", str(file),
"-print_format", "json",
"-show_chapters",
"-loglevel", "error"
]
result = run(cmd)
data = json.loads(result.stdout)
chapters = []
for ch in data.get("chapters", []):
start = float(ch["start_time"])
end = float(ch["end_time"])
duration = end - start
chapters.append((start, duration))
return chapters
def get_intro(file: Path, time: float) -> Tuple[float, float]:
"""
Find the chapter whose duration is closest to `time`,
considering only chapters with duration between time/2 and time*2.
Returns (start_time, duration).
"""
chapters = _get_chapters(file)
if not chapters:
raise ValueError("No chapters found")
# Filter by allowed duration range
min_len = time / 2
max_len = time * 2
filtered = [
(start, dur) for start, dur in chapters
if min_len <= dur <= max_len
]
if not filtered:
raise ValueError("No chapters match duration constraints")
# Sort by closeness to target time
filtered.sort(key=lambda x: abs(x[1] - time))
# Take the two closest
closest_two = filtered[:2]
# Choose the better one (closest duration)
best = min(closest_two, key=lambda x: abs(x[1] - time))
return best
def get_audio_streams(file: Path) -> List[int]:
"""
Return a list of available audio stream indices (0-based for ffmpeg mapping).
Example return:
[0, 1] -> corresponds to 0:a:0 and 0:a:1
"""
cmd = [
"ffprobe",
"-i", str(file),
"-select_streams", "a",
"-show_entries", "stream=index",
"-of", "json",
"-loglevel", "error"
]
result = run(cmd)
data = json.loads(result.stdout)
# ffprobe gives absolute stream indices, but we want relative audio indices
streams = data.get("streams", [])
# Convert to 0-based audio stream numbering
return list(range(len(streams)))
def extract_audio_segment(
file: Path,
intro: Tuple[float, float],
audio_stream: Optional[int] = None
) -> Path:
"""
Extract audio using (start, duration) from get_intro.
Parameters:
file: input video file
intro: (start, duration)
audio_stream: index of audio stream (0 = first audio track, etc.)
Returns:
Path to temporary audio file
"""
start, duration = intro
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),
]
# Select specific audio stream if provided
if audio_stream is not None:
cmd += ["-map", f"0:a:{audio_stream}"]
else:
cmd += ["-map", "0:a:0"] # default: first audio track
cmd += [
"-vn",
"-acodec", "pcm_s16le",
"-ar", "44100",
"-ac", "2",
str(tmp_path)
]
run(cmd)
return tmp_path
def extract_audio(file: Path, duration: float):
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
run([
"ffmpeg",
"-y",
"-i", str(file),
"-map", "0:a:0", # first audio stream
"-ac", "1", # mono
"-ar", "44100", # fixed sample rate
"-t", str(duration), # limit duration
tmp.name
])
data, sr = sf.read(tmp.name)
return data, sr
def get_audio_offset(file1: Path, file2: Path, important_time: float):
sig1, sr1 = extract_audio(file1, important_time)
sig2, sr2 = extract_audio(file2, important_time)
if sr1 != sr2:
raise ValueError("Sample rates do not match")
# normalize signals
sig1 = sig1 - np.mean(sig1)
sig2 = sig2 - np.mean(sig2)
corr = correlate(sig1, sig2, mode="full")
lag = np.argmax(corr) - (len(sig2) - 1)
offset_seconds = lag / sr1
return offset_seconds
def check_final_file(video: Path, fps: float) -> bool:
try:
intro_duration = 90
intro = get_intro(video, 90.0)
audio_streams = get_audio_streams(video)
extract_path1 = extract_audio_segment(video, intro, audio_streams[0])
extract_path2 = extract_audio_segment(video, intro, audio_streams[-1])
offset = get_audio_offset(extract_path1, extract_path2, intro[-1])
if np.abs(offset) < (1/fps):
return True
else:
print(f"NOT CORRECT!!!!!!!!!!!!!!!!!!!!!!!!: {offset}, {1/fps}")
return False
except:
print("Chapter detection not working")
return False