338 lines
13 KiB
Python
Executable File
338 lines
13 KiB
Python
Executable File
"""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()
|