Initial commit: auto-video-cut project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
337
auto_video_cut/cli.py
Executable file
337
auto_video_cut/cli.py
Executable 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.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()
|
||||
Reference in New Issue
Block a user