Initial commit: auto-video-cut project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-04-06 21:51:01 +02:00
commit 267070ad52
15 changed files with 2635 additions and 0 deletions

198
auto_video_cut/cutter.py Executable file
View File

@@ -0,0 +1,198 @@
"""Stille- und Szenen-Erkennung sowie Schnitt-Logik."""
from __future__ import annotations
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
@dataclass
class TimeRange:
start: float
end: float
@property
def duration(self) -> float:
return self.end - self.start
def _run(cmd: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, check=False)
# ---------------------------------------------------------------------------
# Stille-Erkennung
# ---------------------------------------------------------------------------
def detect_silence(
input_path: Path,
threshold_db: float = -40,
min_duration: float = 0.5,
) -> list[TimeRange]:
"""Stille-Abschnitte via ffmpeg silencedetect erkennen."""
cmd = [
"ffmpeg", "-i", str(input_path),
"-af", f"silencedetect=n={threshold_db}dB:d={min_duration}",
"-f", "null", "-",
]
result = _run(cmd)
output = result.stderr
starts = [float(m) for m in re.findall(r"silence_start: ([\d.]+)", output)]
ends = [float(m) for m in re.findall(r"silence_end: ([\d.]+)", output)]
return [TimeRange(s, e) for s, e in zip(starts, ends)]
def _get_duration(input_path: Path) -> float:
"""Video-Länge in Sekunden ermitteln."""
cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(input_path),
]
result = _run(cmd)
return float(result.stdout.strip())
def invert_ranges(silence_ranges: list[TimeRange], total_duration: float) -> list[TimeRange]:
"""Stille-Abschnitte umkehren → Abschnitte mit Ton."""
speech: list[TimeRange] = []
cursor = 0.0
for silence in sorted(silence_ranges, key=lambda r: r.start):
if silence.start > cursor:
speech.append(TimeRange(cursor, silence.start))
cursor = silence.end
if cursor < total_duration:
speech.append(TimeRange(cursor, total_duration))
return speech
def remove_silence(
input_path: Path,
output_path: Path,
threshold_db: float = -40,
min_duration: float = 0.5,
) -> Path:
"""Stille aus Video entfernen und Ergebnis speichern."""
silence = detect_silence(input_path, threshold_db, min_duration)
total = _get_duration(input_path)
segments = invert_ranges(silence, total)
if not segments:
raise RuntimeError("Keine Ton-Abschnitte gefunden — Stille-Schwelle zu hoch?")
output_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
clip_files: list[Path] = []
for i, seg in enumerate(segments):
clip = tmp / f"seg_{i:04d}.mp4"
cmd = [
"ffmpeg", "-y",
"-ss", str(seg.start),
"-to", str(seg.end),
"-i", str(input_path),
"-c", "copy",
str(clip),
]
result = _run(cmd)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg Fehler beim Ausschneiden: {result.stderr}")
clip_files.append(clip)
_concat_clips(clip_files, output_path)
return output_path
# ---------------------------------------------------------------------------
# Szenen-Erkennung
# ---------------------------------------------------------------------------
def detect_scenes(input_path: Path, threshold: float = 27.0) -> list[TimeRange]:
"""Szenen-Grenzen via PySceneDetect erkennen."""
try:
from scenedetect import VideoManager, SceneManager
from scenedetect.detectors import ContentDetector
except ImportError:
raise ImportError("PySceneDetect nicht installiert: pip install scenedetect[opencv]")
video_manager = VideoManager([str(input_path)])
scene_manager = SceneManager()
scene_manager.add_detector(ContentDetector(threshold=threshold))
video_manager.set_downscale_factor()
video_manager.start()
scene_manager.detect_scenes(frame_source=video_manager)
scene_list = scene_manager.get_scene_list()
video_manager.release()
return [
TimeRange(
start.get_seconds(),
end.get_seconds(),
)
for start, end in scene_list
]
def split_scenes(
input_path: Path,
output_folder: Path,
threshold: float = 27.0,
) -> list[Path]:
"""Video an Szenen-Grenzen aufteilen."""
scenes = detect_scenes(input_path, threshold)
output_folder.mkdir(parents=True, exist_ok=True)
clips: list[Path] = []
for i, scene in enumerate(scenes):
out = output_folder / f"scene_{i:04d}.mp4"
cmd = [
"ffmpeg", "-y",
"-ss", str(scene.start),
"-to", str(scene.end),
"-i", str(input_path),
"-c", "copy",
str(out),
]
result = _run(cmd)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg Fehler: {result.stderr}")
clips.append(out)
return clips
# ---------------------------------------------------------------------------
# Hilfsfunktion concat
# ---------------------------------------------------------------------------
def _concat_clips(clips: list[Path], output: Path) -> None:
"""Clips via ffmpeg concat demuxer zusammenfügen."""
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8"
) as fh:
list_file = Path(fh.name)
for clip in clips:
fh.write(f"file '{clip.resolve()}'\n")
cmd = [
"ffmpeg", "-y",
"-f", "concat", "-safe", "0",
"-i", str(list_file),
"-c", "copy",
str(output),
]
result = _run(cmd)
list_file.unlink(missing_ok=True)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg concat Fehler: {result.stderr}")