Files
auto-video-cut/auto_video_cut/cli.py
Christoph K. 267070ad52 Initial commit: auto-video-cut project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:51:01 +02:00

338 lines
13 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()