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

337
auto_video_cut/cli.py Executable file
View File

@@ -0,0 +1,337 @@
"""CLI-Einstiegspunkt für auto-video-cut."""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
from typing import Optional
import typer
app = typer.Typer(
name="video-cut",
help="Automatisches Video-Schnitt-Tool — Stille, Szenen, Musik, Sequenzen.",
add_completion=False,
)
def _load_config(config_path: Optional[Path]) -> dict:
from .config import load_config, validate_config
cfg = load_config(config_path)
warnings = validate_config(cfg)
for w in warnings:
typer.echo(f"[Warnung] {w}", err=True)
return cfg
def _ensure_output(output: Optional[Path], input_path: Path, suffix: str) -> Path:
if output:
return output
return input_path.parent / f"{input_path.stem}{suffix}{input_path.suffix}"
# ---------------------------------------------------------------------------
# video-cut cut
# ---------------------------------------------------------------------------
@app.command()
def cut(
input: Path = typer.Option(..., "--input", "-i", help="Eingabe-Videodatei"),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Ausgabedatei"),
config: Optional[Path] = typer.Option(None, "--config", "-c", help="Konfigurationsdatei"),
remove_silence: bool = typer.Option(False, "--remove-silence", help="Stille entfernen"),
scene_detect: bool = typer.Option(False, "--scene-detect", help="Szenen erkennen und aufteilen"),
) -> None:
"""Video schneiden: Stille entfernen und/oder Szenen erkennen."""
from .config import load_config, validate_config
from .cutter import remove_silence as do_remove_silence, split_scenes
if not input.exists():
typer.echo(f"Fehler: Datei nicht gefunden: {input}", err=True)
raise typer.Exit(1)
cfg = _load_config(config)
if not remove_silence and not scene_detect:
typer.echo("Hinweis: Keine Aktion angegeben. Nutze --remove-silence oder --scene-detect.")
raise typer.Exit(0)
current = input
if remove_silence:
out = _ensure_output(output if not scene_detect else None, input, "_no_silence")
typer.echo(f"Stille entfernen: {current}{out}")
do_remove_silence(
current,
out,
threshold_db=cfg["silence"]["threshold_db"],
min_duration=cfg["silence"]["min_duration"],
)
current = out
typer.echo("Fertig.")
if scene_detect:
out_folder = (output or input.parent / f"{input.stem}_scenes")
typer.echo(f"Szenen erkennen: {current}{out_folder}/")
clips = split_scenes(current, out_folder, threshold=cfg["scenes"]["threshold"])
typer.echo(f"Fertig. {len(clips)} Szenen gespeichert.")
# ---------------------------------------------------------------------------
# video-cut merge
# ---------------------------------------------------------------------------
@app.command()
def merge(
inputs: list[Path] = typer.Option(..., "--inputs", help="Eingabe-Videodateien"),
output: Path = typer.Option(..., "--output", "-o", help="Ausgabedatei"),
intro: Optional[Path] = typer.Option(None, "--intro", help="Intro-Clip"),
outro: Optional[Path] = typer.Option(None, "--outro", help="Outro-Clip"),
no_normalize: bool = typer.Option(False, "--no-normalize", help="Kein Re-Encoding"),
) -> None:
"""Mehrere Video-Clips zusammenführen."""
from .merger import merge_clips
for p in inputs:
if not p.exists():
typer.echo(f"Fehler: Datei nicht gefunden: {p}", err=True)
raise typer.Exit(1)
typer.echo(f"Zusammenführen von {len(inputs)} Clip(s) → {output}")
merge_clips(
clips=list(inputs),
output_path=output,
intro=intro,
outro=outro,
normalize=not no_normalize,
)
typer.echo("Fertig.")
# ---------------------------------------------------------------------------
# video-cut music
# ---------------------------------------------------------------------------
@app.command()
def music(
input: Path = typer.Option(..., "--input", "-i", help="Eingabe-Videodatei"),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Ausgabedatei"),
config: Optional[Path] = typer.Option(None, "--config", "-c", help="Konfigurationsdatei"),
music_file: Optional[Path] = typer.Option(None, "--music-file", help="Direkte Musikdatei (überschreibt config)"),
volume_original: float = typer.Option(1.0, "--vol-orig", help="Lautstärke Original (0.01.0)"),
volume_music: float = typer.Option(0.3, "--vol-music", help="Lautstärke Musik (0.01.0)"),
) -> None:
"""Hintergrundmusik zu einem Video hinzufügen."""
from .audio import mix_music, add_music_from_config
if not input.exists():
typer.echo(f"Fehler: Datei nicht gefunden: {input}", err=True)
raise typer.Exit(1)
out = _ensure_output(output, input, "_music")
if music_file:
if not music_file.exists():
typer.echo(f"Fehler: Musikdatei nicht gefunden: {music_file}", err=True)
raise typer.Exit(1)
typer.echo(f"Musik hinzufügen: {music_file}{out}")
mix_music(input, out, music_file, volume_original, volume_music)
else:
cfg = _load_config(config)
typer.echo(f"Musik aus Konfiguration → {out}")
add_music_from_config(input, out, cfg)
typer.echo("Fertig.")
# ---------------------------------------------------------------------------
# video-cut batch
# ---------------------------------------------------------------------------
@app.command()
def batch(
input: Path = typer.Option(..., "--input", "-i", help="Ordner mit Videos"),
config: Optional[Path] = typer.Option(None, "--config", "-c", help="Konfigurationsdatei"),
remove_silence: bool = typer.Option(False, "--remove-silence"),
scene_detect: bool = typer.Option(False, "--scene-detect"),
add_music: bool = typer.Option(False, "--music", help="Musik hinzufügen"),
) -> None:
"""Alle Videos in einem Ordner verarbeiten."""
from .config import VIDEO_EXTENSIONS
from .cutter import remove_silence as do_remove_silence, split_scenes
from .audio import add_music_from_config
if not input.is_dir():
typer.echo(f"Fehler: Kein Ordner: {input}", err=True)
raise typer.Exit(1)
cfg = _load_config(config)
output_folder = Path(cfg["output"]["folder"])
output_folder.mkdir(parents=True, exist_ok=True)
videos = sorted(f for f in input.iterdir() if f.suffix.lower() in VIDEO_EXTENSIONS)
if not videos:
typer.echo("Keine Videos gefunden.")
raise typer.Exit(0)
typer.echo(f"{len(videos)} Video(s) gefunden.")
for video in videos:
typer.echo(f"\nVerarbeite: {video.name}")
current = video
if remove_silence:
out = output_folder / f"{video.stem}_no_silence.mp4"
typer.echo(f" Stille entfernen → {out.name}")
do_remove_silence(
current, out,
threshold_db=cfg["silence"]["threshold_db"],
min_duration=cfg["silence"]["min_duration"],
)
current = out
if scene_detect:
scene_folder = output_folder / f"{video.stem}_scenes"
typer.echo(f" Szenen → {scene_folder.name}/")
split_scenes(current, scene_folder, threshold=cfg["scenes"]["threshold"])
if add_music and not scene_detect:
out = output_folder / f"{video.stem}_music.mp4"
typer.echo(f" Musik → {out.name}")
add_music_from_config(current, out, cfg)
typer.echo("\nBatch abgeschlossen.")
# ---------------------------------------------------------------------------
# video-cut sequence
# ---------------------------------------------------------------------------
@app.command()
def sequence(
seq_file: Path = typer.Option(..., "--seq", "-s", help="Sequenz-Datei (sequence.yaml)"),
config: Optional[Path] = typer.Option(None, "--config", "-c", help="Konfigurationsdatei"),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Ausgabedatei"),
add_music: bool = typer.Option(True, "--music/--no-music", help="Musik hinzufügen"),
) -> None:
"""Video aus sequence.yaml zusammenstellen."""
from .config import get_resources_folder
from .sequencer import parse_sequence, ClipEntry
from .cutter import remove_silence as do_remove_silence
from .merger import merge_clips, image_to_clip
from .text import create_text_clip, add_text_overlay
from .audio import add_music_from_config, mix_music, pick_music_file, get_music_files
if not seq_file.exists():
typer.echo(f"Fehler: Sequenz-Datei nicht gefunden: {seq_file}", err=True)
raise typer.Exit(1)
cfg = _load_config(config)
resources = get_resources_folder(cfg)
typer.echo(f"Sequenz laden: {seq_file}")
clips_raw, seq_music = parse_sequence(
seq_file,
resources_folder=resources,
default_image_duration=cfg["images"]["duration"],
)
typer.echo(f"{len(clips_raw)} Einträge in der Sequenz.")
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
ready_clips: list[Path] = []
for i, entry in enumerate(clips_raw):
typer.echo(f" [{i+1}/{len(clips_raw)}] {entry.media_type}: {entry.path.name}")
if entry.media_type == "image":
clip = tmp / f"clip_{i:04d}.mp4"
image_to_clip(entry.path, clip, duration=entry.image_duration)
ready_clips.append(clip)
elif entry.media_type == "text":
style = getattr(entry, "text_style", {}) or {}
clip = tmp / f"clip_{i:04d}.mp4"
create_text_clip(
output_path=clip,
content=entry.overlay_text or "",
duration=entry.image_duration,
font_size=style.get("font_size", 72),
font_color=style.get("font_color", "white"),
background_color=style.get("background_color", "black"),
position=style.get("position", "center"),
)
ready_clips.append(clip)
elif entry.media_type == "video":
current = entry.path
if entry.remove_silence or entry.trim_silence:
silenced = tmp / f"clip_{i:04d}_ns.mp4"
do_remove_silence(
current, silenced,
threshold_db=cfg["silence"]["threshold_db"],
min_duration=cfg["silence"]["min_duration"],
)
current = silenced
if entry.overlay_text:
overlaid = tmp / f"clip_{i:04d}_overlay.mp4"
add_text_overlay(
current, overlaid,
text=entry.overlay_text,
position=entry.overlay_position,
duration=entry.overlay_duration,
)
current = overlaid
ready_clips.append(current)
if not ready_clips:
typer.echo("Fehler: Keine Clips zum Zusammenführen.", err=True)
raise typer.Exit(1)
# Finales Video ohne Musik
out_cfg = Path(cfg["output"]["folder"])
out_cfg.mkdir(parents=True, exist_ok=True)
final_name = output or out_cfg / "output.mp4"
no_music_path = tmp / "merged.mp4"
typer.echo(f"Clips zusammenführen ({len(ready_clips)} Stück)…")
merge_clips(ready_clips, no_music_path, normalize=True)
# Musik
if add_music:
music_files = get_music_files(cfg)
seq_music_file = seq_music.get("file")
if seq_music_file and seq_music_file != "random":
m_path = resources / "music" / seq_music_file
if m_path.exists():
music_files = [m_path]
if music_files:
mode = cfg["music"]["mode"]
chosen = pick_music_file(music_files, mode)
vol_orig = seq_music.get("volume_original", cfg["music"]["volume_original"])
vol_music = seq_music.get("volume_music", cfg["music"]["volume_music"])
typer.echo(f"Musik hinzufügen: {chosen.name}")
mix_music(no_music_path, Path(final_name), chosen, vol_orig, vol_music)
else:
import shutil
shutil.copy2(no_music_path, final_name)
typer.echo("Keine Musikdateien gefunden — ohne Musik gespeichert.")
else:
import shutil
shutil.copy2(no_music_path, final_name)
typer.echo(f"\nFertig: {final_name}")
def main() -> None:
app()
if __name__ == "__main__":
main()