"""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.0–1.0)"), volume_music: float = typer.Option(0.3, "--vol-music", help="Lautstärke Musik (0.0–1.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()