From 267070ad52c41462eb51bdbbdf1573d8c3f6e723 Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Mon, 6 Apr 2026 21:51:01 +0200 Subject: [PATCH] Initial commit: auto-video-cut project Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 17 + ANFORDERUNGEN.md | 607 +++++++++++++++++++++++++++++++ PLAN.md | 708 ++++++++++++++++++++++++++++++++++++ auto_video_cut/__init__.py | 3 + auto_video_cut/audio.py | 116 ++++++ auto_video_cut/cli.py | 337 +++++++++++++++++ auto_video_cut/config.py | 115 ++++++ auto_video_cut/cutter.py | 198 ++++++++++ auto_video_cut/merger.py | 117 ++++++ auto_video_cut/sequencer.py | 149 ++++++++ auto_video_cut/text.py | 105 ++++++ config.example.yaml | 27 ++ pyproject.toml | 22 ++ sequence.example.yaml | 51 +++ setup.sh | 63 ++++ 15 files changed, 2635 insertions(+) create mode 100755 .gitignore create mode 100755 ANFORDERUNGEN.md create mode 100644 PLAN.md create mode 100755 auto_video_cut/__init__.py create mode 100755 auto_video_cut/audio.py create mode 100755 auto_video_cut/cli.py create mode 100755 auto_video_cut/config.py create mode 100755 auto_video_cut/cutter.py create mode 100755 auto_video_cut/merger.py create mode 100755 auto_video_cut/sequencer.py create mode 100755 auto_video_cut/text.py create mode 100755 config.example.yaml create mode 100755 pyproject.toml create mode 100755 sequence.example.yaml create mode 100755 setup.sh diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..3178025 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +output/ +*.mp4 +*.mov +*.avi +*.mkv +*.mp3 +*.wav +*.flac +*.aac +*.ogg diff --git a/ANFORDERUNGEN.md b/ANFORDERUNGEN.md new file mode 100755 index 0000000..de4e299 --- /dev/null +++ b/ANFORDERUNGEN.md @@ -0,0 +1,607 @@ +# Anforderungen: auto-video-cut + +Stand: 2026-03-20 (aktualisiert: KI-Features, Übergänge, Audio-Ducking) + +--- + +## 1. Ziel + +`auto-video-cut` ist ein vollständig automatisiertes Kommandozeilen-Tool für Videoschnitt, -zusammenführung und Musikunterlegung. Keine GUI, keine manuelle Timeline. Zielgruppe: technisch versierte Einzelnutzer mit wiederholenden Video-Workflows. + +--- + +## 2. Systemvoraussetzungen + +| Anforderung | Detail | +|-------------|--------| +| Python | >= 3.10 | +| ffmpeg | Im PATH verfügbar (für alle Video-/Audio-Operationen) | +| ffprobe | Im PATH verfügbar (für Metadaten-Abfragen) | +| Betriebssystem | Plattformunabhängig (getestet unter Linux/WSL, macOS, Windows) | + +--- + +## 3. Python-Abhängigkeiten + +| Paket | Version | Verwendung | +|-------|---------|------------| +| `typer` | >= 0.12 | CLI-Framework | +| `pyyaml` | >= 6.0 | YAML-Konfiguration und Sequenz-Dateien | +| `scenedetect[opencv]` | >= 0.6 | Szenen-Erkennung via ContentDetector | +| `ffmpeg-python` | >= 0.2 | ffmpeg-Binding (Hilfsfunktionen) | +| `discord.py` | >= 2.3 | Discord-Bot für Fernsteuerung | +| `rich` | >= 13.0 | Fortschrittsanzeige und Terminal-UI | + +Optionale KI-Abhängigkeiten (`pip install -e ".[ai]"`): + +| Paket | Version | Verwendung | +|-------|---------|------------| +| `faster-whisper` | >= 1.0 | Lokale Sprach-Transkription (Whisper) | +| `anthropic` | >= 0.40 | LLM-Anbindung für Auto-Kapitel, Highlights, Beschreibungen | + +Installation: `pip install -e .` +CLI-Einstiegspunkt: `video-cut` + +--- + +## 4. Funktionale Anforderungen + +### 4.1 Stille-Erkennung und -Entfernung + +- **F-01** Das Tool erkennt Stille-Abschnitte in einem Video via `ffmpeg silencedetect`. +- **F-02** Erkannte Stille-Abschnitte werden aus dem Video herausgeschnitten; die verbleibenden Ton-Abschnitte werden nahtlos zusammengefügt. +- **F-03** Konfigurierbare Parameter: + - `threshold_db` — Schwelle in dB, ab der Stille erkannt wird (Standard: `-40`) + - `min_duration` — Mindestdauer in Sekunden, ab der Stille als solche gilt (Standard: `0.5`) +- **F-04** Stille-Erkennung ist pro Clip in der Sequenz-Datei einzeln aktivierbar (`remove_silence: true` / `trim_silence: true`). + +### 4.2 Szenen-Erkennung + +- **F-05** Das Tool erkennt Szenen-Grenzen via PySceneDetect (`ContentDetector`). +- **F-06** Das Video wird an erkannten Szenen-Grenzen in einzelne Clips aufgeteilt und in einen Ausgabe-Ordner gespeichert. +- **F-07** Konfigurierbare Parameter: + - `threshold` — Empfindlichkeit der Szenen-Erkennung (Standard: `27.0`) + +### 4.3 Clips zusammenführen + +- **F-08** Mehrere Video-Clips werden via `ffmpeg concat` zu einem einzigen Video zusammengeführt. +- **F-09** Vor dem Zusammenführen werden Clips optional auf ein einheitliches Format re-encodet (H.264, AAC, yuv420p, 25fps), um Kompatibilitätsprobleme zu vermeiden. +- **F-10** Optional: Intro-Clip vor dem ersten und Outro-Clip nach dem letzten Inhalt einfügen. + +### 4.4 Hintergrundmusik + +- **F-11** Musik-Dateien werden aus einem konfigurierbaren Ordner (`resources/music/`) gelesen. +- **F-12** Unterstützte Musikformate: `.mp3`, `.wav`, `.flac`, `.aac`, `.ogg` +- **F-13** Auswahlmodus für Musik: + - `random` — zufällig aus dem Ordner + - `alphabetical` — erste Datei alphabetisch + - `loop` — erste Datei alphabetisch (wird geloopt) +- **F-14** Musik wird auf die Länge des Videos getrimmt (`-shortest`) oder geloopt (`-stream_loop -1`). +- **F-15** Original-Audio und Hintergrundmusik werden gemischt via `ffmpeg amix` mit konfigurierbaren Lautstärken: + - `volume_original` — Lautstärke des Original-Tons (Standard: `1.0`) + - `volume_music` — Lautstärke der Hintergrundmusik (Standard: `0.3`) +- **F-16** Hat das Video keinen Audio-Track, wird die Musik direkt als einziger Audio-Track eingefügt. +- **F-17** In der Sequenz-Datei kann eine konkrete Musikdatei oder `"random"` angegeben werden; Lautstärken sind pro Sequenz überschreibbar. + +### 4.5 Text-Einblendungen + +- **F-18** Text wird als Standbild-Clip auf einem konfigurierbaren Hintergrund (Farbe oder schwarz) gerendert via `ffmpeg drawtext` + `lavfi color`. +- **F-19** Text-Overlays können über laufende Video-Clips gelegt werden, mit optionaler Zeitbegrenzung (`enable='between(t,0,N)'`). +- **F-20** Konfigurierbare Text-Optionen: + - `content` — Text-Inhalt + - `duration` — Anzeigedauer in Sekunden (für Standbild-Clips) + - `font_size` — Schriftgröße (Standard: `72` für Clips, `48` für Overlays) + - `font_color` — Schriftfarbe (Standard: `"white"`) + - `background_color` — Hintergrundfarbe oder `"transparent"` (Standard: `"black"`) + - `position` — `center` | `top` | `bottom` (Standard: `"center"`) +- **F-21** Sonderzeichen im Text (`'`, `:`, `\`) werden für ffmpeg korrekt escapt. + +### 4.6 Bilder als Clips + +- **F-22** Einzelne Bilder (`.png`, `.jpg`, `.jpeg`) werden in Video-Clips konvertiert (`ffmpeg -loop 1`). +- **F-23** Konfigurierbare Anzeigedauer in Sekunden. +- **F-24** Bilder werden auf die Zielauflösung skaliert und bei abweichendem Seitenverhältnis mit schwarzen Balken aufgefüllt (letterbox). +- **F-25** Standard-Auflösung: 1920×1080, 25fps. + +### 4.7 Sequenz-Verarbeitung + +- **F-26** Eine `sequence.yaml`-Datei definiert die Timeline des finalen Videos: Reihenfolge, Quellen und Verarbeitungsoptionen aller Elemente. +- **F-27** Unterstützte Sequenz-Typen: + +| Typ | Beschreibung | +|-----|-------------| +| `video` | Einzelne Videodatei | +| `image` | Standbild für konfigurierbare Dauer | +| `text` | Text auf Hintergrundfarbe als Clip | +| `folder` | Alle Medien in einem Ordner, sortierbar | + +- **F-28** Ordner-Einträge (`type: folder`) unterstützen Sortierung nach: + - `alphabetical` — alphabetisch nach Dateiname + - `date` — nach Änderungsdatum +- **F-29** Datei-Pfade in der Sequenz werden aufgelöst: absolut → relativ → aus `resources/`-Unterordner. +- **F-30** Musik-Konfiguration kann direkt in der `sequence.yaml` angegeben werden und überschreibt die globale `config.yaml`. + +### 4.8 Batch-Verarbeitung + +- **F-31** Alle Video-Dateien in einem Ordner werden sequenziell verarbeitet. +- **F-32** Unterstützte Video-Formate: `.mp4`, `.mov`, `.avi`, `.mkv` +- **F-33** Jede Verarbeitungs-Option (Stille entfernen, Szenen, Musik) ist einzeln für den Batch aktivierbar. +- **F-34** Ausgabe-Dateien werden in den konfigurierten Output-Ordner gespeichert. + +--- + +## 5. Ressourcen-Struktur + +``` +resources/ +├── music/ ← .mp3, .wav, .flac, .aac, .ogg +├── videos/ ← Intros, Outros, Übergänge (.mp4, .mov, .avi, .mkv) +└── images/ ← Titelkarten, Folien (.png, .jpg, .jpeg) +``` + +- **F-35** Der Ressourcen-Ordner ist über `resources.folder` in der Konfiguration frei wählbar. +- **F-36** Das Tool gibt eine Warnung aus, wenn der Ressourcen-Ordner oder der Musik-Unterordner nicht existiert oder leer ist — kein harter Abbruch. + +--- + +## 6. Konfiguration (`config.yaml`) + +Alle Parameter haben Standardwerte; eine Konfigurationsdatei ist optional. + +| Schlüssel | Typ | Standard | Beschreibung | +|-----------|-----|---------|--------------| +| `resources.folder` | string | `"./resources"` | Basis-Ordner für Ressourcen | +| `music.mode` | string | `"random"` | Musik-Auswahlmodus | +| `music.volume_original` | float | `1.0` | Lautstärke Original-Ton | +| `music.volume_music` | float | `0.3` | Lautstärke Hintergrundmusik | +| `videos.intro` | string | `null` | Dateiname des Intro-Clips | +| `videos.outro` | string | `null` | Dateiname des Outro-Clips | +| `videos.transitions` | bool | `false` | Übergangs-Clips einsetzen | +| `images.title_card` | string | `null` | Dateiname der Titelkarte | +| `images.duration` | int | `3` | Standard-Anzeigedauer für Bilder (s) | +| `silence.threshold_db` | float | `-40` | Stille-Schwelle in dB | +| `silence.min_duration` | float | `0.5` | Minimale Stille-Dauer in Sekunden | +| `scenes.threshold` | float | `27.0` | Szenen-Erkennungs-Schwelle | +| `output.format` | string | `"mp4"` | Ausgabeformat | +| `output.folder` | string | `"./output"` | Ausgabe-Ordner | + +- **F-37** Fehlende Schlüssel in der Konfigurationsdatei werden mit Standardwerten aufgefüllt (Deep-Merge). +- **F-38** Beim Laden der Konfiguration werden Warnungen für ungültige Lautstärke-Werte ausgegeben. + +--- + +## 7. CLI-Befehle + +### `video-cut cut` + +``` +video-cut cut --input VIDEO [--output DATEI] [--config CONFIG] + [--remove-silence] [--scene-detect] +``` + +| Option | Beschreibung | +|--------|-------------| +| `--input`, `-i` | Eingabe-Videodatei (Pflicht) | +| `--output`, `-o` | Ausgabedatei (Standard: `_no_silence.mp4`) | +| `--config`, `-c` | Konfigurationsdatei | +| `--remove-silence` | Stille entfernen | +| `--scene-detect` | Szenen erkennen und aufteilen | + +### `video-cut merge` + +``` +video-cut merge --inputs CLIP1 CLIP2 [...] --output DATEI + [--intro CLIP] [--outro CLIP] [--no-normalize] +``` + +| Option | Beschreibung | +|--------|-------------| +| `--inputs` | Eingabe-Clips (Pflicht, mehrfach) | +| `--output`, `-o` | Ausgabedatei (Pflicht) | +| `--intro` | Optionaler Intro-Clip | +| `--outro` | Optionaler Outro-Clip | +| `--no-normalize` | Re-Encoding überspringen | + +### `video-cut music` + +``` +video-cut music --input VIDEO [--output DATEI] [--config CONFIG] + [--music-file DATEI] [--vol-orig 1.0] [--vol-music 0.3] +``` + +| Option | Beschreibung | +|--------|-------------| +| `--input`, `-i` | Eingabe-Videodatei (Pflicht) | +| `--output`, `-o` | Ausgabedatei (Standard: `_music.mp4`) | +| `--config`, `-c` | Konfigurationsdatei | +| `--music-file` | Direkte Musikdatei (überschreibt config) | +| `--vol-orig` | Lautstärke Original (Standard: `1.0`) | +| `--vol-music` | Lautstärke Musik (Standard: `0.3`) | + +### `video-cut batch` + +``` +video-cut batch --input ORDNER [--config CONFIG] + [--remove-silence] [--scene-detect] [--music] +``` + +| Option | Beschreibung | +|--------|-------------| +| `--input`, `-i` | Ordner mit Videos (Pflicht) | +| `--config`, `-c` | Konfigurationsdatei | +| `--remove-silence` | Stille aus allen Videos entfernen | +| `--scene-detect` | Szenen in allen Videos erkennen | +| `--music` | Musik zu allen Videos hinzufügen | + +### `video-cut sequence` + +``` +video-cut sequence --seq SEQUENZ.yaml [--config CONFIG] + [--output DATEI] [--music/--no-music] +``` + +| Option | Beschreibung | +|--------|-------------| +| `--seq`, `-s` | Sequenz-Datei (Pflicht) | +| `--config`, `-c` | Konfigurationsdatei | +| `--output`, `-o` | Ausgabedatei (Standard: `output/output.mp4`) | +| `--music` / `--no-music` | Musik hinzufügen oder nicht (Standard: an) | + +--- + +## 8. Discord-Integration (Fernsteuerung) + +### 8.1 Überblick + +Die Applikation soll über einen Discord-Bot ferngesteuert werden können. Der Bot läuft als Hintergrund-Prozess auf demselben Server wie `auto-video-cut` und empfängt Befehle über Discord-Slash-Commands. Ergebnisse und Fortschrittsmeldungen werden in den aufrufenden Discord-Kanal zurückgemeldet. + +### 8.2 Architektur + +``` +Discord-Nutzer + │ Slash-Command (/cut, /music, ...) + ▼ +Discord API + │ discord.py Bot (auto_video_cut/bot.py) + ▼ +CLI-Logik (cutter, merger, audio, sequencer, ...) + │ Ergebnis / Fortschritt + ▼ +Discord API → Antwort im Kanal +``` + +Der Bot ruft intern dieselben Python-Funktionen auf wie die CLI — kein separater Prozess, keine HTTP-API. + +### 8.3 Konfiguration + +Neue Schlüssel in `config.yaml`: + +```yaml +discord: + token: "BOT_TOKEN" # Discord-Bot-Token (Pflicht) + allowed_channel_ids: # Whitelist der erlaubten Kanal-IDs + - 123456789012345678 + allowed_user_ids: # Whitelist der erlaubten Nutzer-IDs (optional) + - 987654321098765432 + upload_results: true # Fertige Videos direkt in Discord hochladen (≤ 25 MB) + notify_on_complete: true # Abschluss-Meldung immer senden +``` + +- **F-39** Der Bot-Token wird aus der Konfigurationsdatei oder alternativ aus der Umgebungsvariable `VIDEO_CUT_DISCORD_TOKEN` gelesen. +- **F-40** Befehle werden nur in explizit erlaubten Kanälen akzeptiert; alle anderen Anfragen werden still ignoriert. +- **F-41** Optional: Whitelist auf bestimmte Discord-Nutzer-IDs einschränken. + +### 8.4 Bot starten + +```bash +video-cut bot --config config.yaml +``` + +Der Bot läuft als Langzeit-Prozess (Blocking Event Loop). Empfohlen: `systemd`-Service oder `screen`/`tmux`-Session. + +### 8.5 Slash-Commands + +Alle CLI-Befehle werden als Discord-Slash-Commands gespiegelt. Dateipfade beziehen sich immer auf den Server, auf dem der Bot läuft. + +#### `/cut` + +``` +/cut input: [remove_silence:true|false] [scene_detect:true|false] [config:] +``` + +- Schneidet ein Video (Stille entfernen und/oder Szenen aufteilen) +- Antwort: Fortschritts-Embed → Abschluss-Embed mit Ausgabepfad + +#### `/merge` + +``` +/merge inputs: output: [intro:] [outro:] +``` + +- Fügt mehrere Clips zusammen + +#### `/music` + +``` +/music input: [config:] [music_file:] [vol_orig:1.0] [vol_music:0.3] +``` + +- Legt Hintergrundmusik unter ein Video + +#### `/batch` + +``` +/batch input: [config:] [remove_silence:true] [music:true] +``` + +- Verarbeitet alle Videos in einem Ordner + +#### `/sequence` + +``` +/sequence seq: [config:] [output:] [music:true|false] +``` + +- Rendert ein Video aus einer `sequence.yaml` + +#### `/status` + +``` +/status +``` + +- Zeigt laufende Jobs, Warteschlange und System-Status (ffmpeg vorhanden, Ressourcen-Ordner erreichbar) + +#### `/cancel` + +``` +/cancel [job_id:] +``` + +- Bricht den aktuellen oder einen bestimmten Job ab + +### 8.6 Antwort-Format + +- **F-42** Jeder Befehl wird mit einem Discord-Embed bestätigt: Job-ID, Eingabe-Parameter, Startzeitpunkt. +- **F-43** Während der Verarbeitung werden Fortschritts-Updates als bearbeitete Embed-Nachricht gesendet (alle ~10 Sekunden oder bei Meilenstein: Stille erkannt, Clips zusammengeführt, Musik gemischt). +- **F-44** Bei Abschluss: Embed mit Ausgabepfad, Dauer der Verarbeitung, Dateigröße. +- **F-45** Ist die fertige Datei ≤ 25 MB und `upload_results: true`, wird sie direkt in Discord hochgeladen. +- **F-46** Bei Fehlern: Embed mit Fehlermeldung und dem betroffenen Verarbeitungsschritt (roter Embed-Rand). + +### 8.7 Job-Verwaltung + +- **F-47** Jobs werden sequenziell in einer FIFO-Warteschlange abgearbeitet — kein paralleles Rendering, da ffmpeg die CPU auslastet. +- **F-48** Jeder Job erhält eine eindeutige Job-ID (kurzer UUID-Präfix), die in allen Nachrichten angezeigt wird. +- **F-49** Läuft bereits ein Job, wird der neue Job mit geschätzter Warteposition bestätigt (`"Job #3 in Warteschlange"`). +- **F-50** `/cancel` bricht laufende ffmpeg-Subprozesse sauber ab (`process.terminate()`). + +### 8.8 Sicherheit + +- **F-51** Dateipfade aus Discord-Nachrichten werden auf erlaubte Basis-Verzeichnisse beschränkt (konfigurierbar: `allowed_paths`). Path-Traversal-Angriffe (`../`) werden abgewiesen. +- **F-52** Shell-Injection ist ausgeschlossen, da alle ffmpeg-Aufrufe als Argument-Listen via `subprocess` erfolgen (kein `shell=True`). +- **F-53** Der Bot-Token wird niemals in Logs oder Discord-Nachrichten ausgegeben. + +### 8.9 Neue Projektdatei + +``` +auto_video_cut/ +└── bot.py ← Discord-Bot (discord.py, Slash-Commands, Job-Queue) +``` + +--- + +## 9. Video-Übergänge und Effekte + +### 9.1 Crossfade + +- **F-54** Zwischen zwei Clips kann ein Crossfade (weicher Übergang) eingefügt werden via ffmpeg `xfade` Filter. +- **F-55** Konfigurierbare Parameter pro Sequenz-Eintrag: + - `transition` — Übergangstyp: `crossfade` | `dissolve` | `wipeleft` (Standard: keiner) + - `transition_duration` — Dauer in Sekunden (Standard: `0.5`) +- **F-56** Globale Fade-Optionen: + - `fade_in` — Einblende-Dauer am Anfang des Gesamtvideos + - `fade_out` — Ausblende-Dauer am Ende des Gesamtvideos + +### 9.2 Audio-Ducking + +- **F-57** Hintergrundmusik wird automatisch leiser wenn Original-Audio (Sprache) erkannt wird, via ffmpeg `sidechaincompress`. +- **F-58** Konfigurierbare Ducking-Parameter: + - `ducking` — aktivieren/deaktivieren (Standard: `false`) + - `duck_threshold` — ab welcher Lautstärke die Musik abgesenkt wird (Standard: `0.02`) + - `duck_ratio` — Stärke der Absenkung (Standard: `4.0`) + - `duck_attack` — wie schnell die Musik leiser wird in Sekunden (Standard: `0.3`) + - `duck_release` — wie schnell die Musik wieder lauter wird in Sekunden (Standard: `1.0`) + +### 9.3 Fortschrittsanzeige + +- **F-59** Alle ffmpeg-Operationen zeigen einen Echtzeit-Fortschrittsbalken mit Prozent und geschätzter Restzeit via `rich`. +- **F-60** ffmpeg wird mit `-progress pipe:1` gestartet; `out_time_ms` wird geparst und gegen die Gesamt-Dauer gerechnet. + +### 9.4 Preview und Dry-Run + +- **F-61** `--preview` erzeugt eine niedrig aufgelöste Schnellversion (360p, ultrafast-Preset) im Temp-Ordner. +- **F-62** `--dry-run` zeigt die geplante Verarbeitung ohne zu rendern: Clip-Liste, geschätzte Gesamtdauer, geschätzte Dateigröße. + +--- + +## 10. KI-Features + +### 10.1 Whisper-Transkription + +- **F-63** Audio wird aus dem Video extrahiert (WAV, 16kHz Mono) und lokal mit `faster-whisper` transkribiert. +- **F-64** Konfigurierbare Whisper-Parameter: + - `whisper_model` — Modellgröße: `tiny` | `base` | `small` | `medium` | `large-v3` (Standard: `base`) + - `whisper_language` — Sprache oder `null` für Auto-Erkennung (Standard: `null`) + - `whisper_device` — Gerät: `auto` | `cpu` | `cuda` (Standard: `auto`) +- **F-65** Das Transkript enthält Wort-Level-Timestamps (Start, Ende, Konfidenz pro Wort). +- **F-66** KI-Features sind optional: fehlt `faster-whisper`, wird eine verständliche Fehlermeldung ausgegeben mit Installationshinweis. + +### 10.2 Automatische Untertitel + +- **F-67** Aus dem Whisper-Transkript wird eine `.srt`-Datei mit korrekten Timestamps erzeugt. +- **F-68** Untertitel werden via ffmpeg `subtitles`-Filter ins Video eingebrannt. +- **F-69** Konfigurierbare Stil-Optionen: Schriftgröße, Farbe, Umrissfarbe, Position. +- **F-70** In der Sequenz-Datei pro Clip aktivierbar: `auto_subtitles: true`. +- **F-71** Alternativ: bestehende `.srt`-Datei direkt einbrennen ohne Whisper. + +### 10.3 Intelligentes Schneiden (Smart Cut) + +- **F-72** Füllwort-Erkennung: Deutsche und englische Füllwörter ("äh", "ähm", "also", "halt", "uh", "um") werden im Transkript erkannt und als Schnitt-Kandidaten markiert. +- **F-73** Fehlstart-Erkennung: Sätze die abbrechen und neu begonnen werden, werden erkannt (Heuristik: kurzes Segment + Pause + ähnlicher Neubeginn). +- **F-74** Pausen-Kürzung: Pausen zwischen Segmenten werden auf eine konfigurierbare Maximaldauer gekürzt (Standard: `1.0s` → `0.3s`). +- **F-75** `--analyze-only` zeigt eine Aufschlüsselung aller erkannten Schnitt-Kandidaten (Typ, Zeitposition, eingesparte Sekunden) ohne das Video zu schneiden. +- **F-76** Jede Schnitt-Kategorie (Füllwörter, Fehlstarts, Pausen) ist einzeln aktivierbar/deaktivierbar. +- **F-77** In der Sequenz-Datei pro Clip aktivierbar: `smart_cut: true`. + +### 10.4 Auto-Kapitel + +- **F-78** Das Whisper-Transkript wird an ein LLM (Claude oder lokales Modell via Ollama) gesendet, das inhaltliche Kapitel mit Titeln erzeugt. +- **F-79** Unterstützte LLM-Provider: + - `anthropic` — Claude API (API-Key aus Config oder `ANTHROPIC_API_KEY`) + - `ollama` — lokales Modell (URL und Modellname konfigurierbar) +- **F-80** Export-Formate für Kapitel: + - YouTube-kompatibel (`0:00 Intro`, `0:45 Ankunft in Berlin`, ...) + - Als `type: text`-Einträge für die Sequenz-Datei (Kapitelmarken einblenden) +- **F-81** Maximale Kapitelanzahl konfigurierbar (Standard: `10`). + +### 10.5 Highlight-Reel + +- **F-82** Transkript + Szenen-Erkennung → LLM bewertet jede Szene nach inhaltlichem Interesse (Schlüsselaussagen, Emotionen, Themenwechsel). +- **F-83** Die besten Szenen werden chronologisch ausgewählt und zu einem Highlight-Video zusammengeschnitten. +- **F-84** Ziel-Dauer des Highlight-Reels ist konfigurierbar (Standard: `60s`). +- **F-85** Zwischen Highlight-Szenen werden automatisch Crossfades eingefügt. + +### 10.6 Natürlichsprachliche Sequenz-Erstellung + +- **F-86** Eine natürlichsprachliche Beschreibung wird per LLM in eine vollständige `sequence.yaml` übersetzt. +- **F-87** Das LLM erhält als Kontext: verfügbare Ressourcen (Musik, Intros, Bilder), Dateien im angegebenen Ordner, und das Schema der Sequenz-Datei. +- **F-88** Die generierte Sequenz wird zur Bestätigung angezeigt, bevor optional direkt gerendert wird (`--execute`). + +--- + +## 11. KI-Konfiguration + +Neuer Block in `config.yaml`: + +```yaml +ai: + whisper_model: "base" + whisper_language: null + whisper_device: "auto" + llm_provider: "anthropic" + llm_model: "claude-haiku-4-5-20251001" + anthropic_api_key: null + ollama_url: "http://localhost:11434" + ollama_model: "llama3" +``` + +- **F-89** Der Anthropic-API-Key wird aus der Config oder der Umgebungsvariable `ANTHROPIC_API_KEY` gelesen. +- **F-90** Fehlt der API-Key, funktionieren alle Features außer Auto-Kapitel, Highlights und natürlichsprachliche Sequenzen. + +--- + +## 12. Neue CLI-Befehle + +### `video-cut smart-cut` + +``` +video-cut smart-cut --input VIDEO [--output DATEI] + [--keep-fillers] [--no-false-starts] + [--max-pause 1.0] [--analyze-only] +``` + +### `video-cut transcribe` + +``` +video-cut transcribe --input VIDEO [--output DATEI.srt] + [--model base] [--language de] +``` + +### `video-cut subtitle` + +``` +video-cut subtitle --input VIDEO [--output DATEI] + [--srt DATEI.srt] +``` + +### `video-cut chapters` + +``` +video-cut chapters --input VIDEO [--output chapters.txt] + [--format youtube|sequence] + [--inject-titles] +``` + +### `video-cut highlights` + +``` +video-cut highlights --input VIDEO [--output highlights.mp4] + [--duration 60] +``` + +### `video-cut describe` + +``` +video-cut describe "Beschreibung des gewünschten Videos" + [--execute] [--output sequence.yaml] +``` + +--- + +## 13. Nicht-funktionale Anforderungen + +- **NF-01** Kein GUI — ausschließlich Kommandozeile. +- **NF-02** Alle Video-Operationen laufen über `subprocess` + `ffmpeg`/`ffprobe`; kein proprietäres Video-Framework. +- **NF-03** Temporäre Dateien werden in einem `tempfile.TemporaryDirectory()` erzeugt und nach Abschluss automatisch gelöscht. +- **NF-04** Fehlermeldungen erscheinen auf `stderr`; Fortschritts-Ausgaben auf `stdout`. +- **NF-05** Exit-Code `0` bei Erfolg, `1` bei Fehler. +- **NF-06** Ausgabe-Ordner werden automatisch angelegt, wenn sie nicht existieren. +- **NF-07** Standardauflösung für generierte Clips (Text, Bilder): 1920×1080 @ 25fps, H.264, AAC. +- **NF-08** Das Paket ist via `pip install -e .` installierbar (PEP 517, `setuptools`). +- **NF-09** Der Discord-Bot läuft als Langzeit-Prozess; er blockiert nicht den CLI-Betrieb — beide Modi sind unabhängig voneinander nutzbar. +- **NF-10** Discord-Befehle und CLI-Befehle rufen dieselbe interne Logik auf — keine duplizierte Implementierung. +- **NF-11** KI-Features (`faster-whisper`, `anthropic`) sind optionale Abhängigkeiten — das Tool funktioniert ohne sie. +- **NF-12** Whisper läuft lokal, keine Daten werden an externe Server gesendet (außer bei LLM-Provider `anthropic`). + +--- + +## 14. Bekannte Einschränkungen + +- **E-01** `--no-normalize` beim `merge`-Befehl kann zu Kompatibilitätsproblemen führen, wenn die Clips unterschiedliche Codecs oder Framerates haben. +- **E-02** `type: folder` in der Sequenz-Datei erkennt Dateitypen nur anhand der Dateiendung, nicht des tatsächlichen Inhalts. +- **E-03** Text-Overlays setzen voraus, dass die Standard-Schriftart von ffmpeg (`default`) auf dem System verfügbar ist; für abweichende Fonts muss `fontfile` in der drawtext-Option ergänzt werden (aktuell nicht konfigurierbar). +- **E-04** Szenen-Erkennung via `split_scenes` setzt PySceneDetect und OpenCV voraus; fehlt das Paket, wird eine klare Fehlermeldung ausgegeben. +- **E-05** `music.mode: loop` wiederholt aktuell nur die erste alphabetische Datei, kein echter Playlist-Loop über mehrere Dateien. +- **E-06** Discord-Uploads sind auf 25 MB begrenzt (Discord-Limit für reguläre Server). Größere Ausgabe-Videos können nur als Pfad-Referenz zurückgemeldet werden. +- **E-07** Slash-Commands müssen einmalig bei Discord registriert werden (`bot.tree.sync()`); nach Deployment kann die Synchronisation einige Minuten dauern. +- **E-08** Der Bot unterstützt nur einen Server gleichzeitig sinnvoll — `allowed_channel_ids` verhindert ungewollten Multi-Server-Betrieb. +- **E-09** Whisper-Modelle `medium` und `large-v3` benötigen signifikant mehr RAM/VRAM; auf CPU-only-Systemen empfohlen: `tiny` oder `base`. +- **E-10** Füllwort-Erkennung kann kontextabhängige Wörter falsch markieren (z.B. "also" als Satzanfang vs. Füllwort). `--analyze-only` ermöglicht Review vor dem Schnitt. +- **E-11** Auto-Kapitel und Highlights benötigen einen LLM-API-Zugang (Anthropic oder Ollama). Ohne API-Key sind diese Features deaktiviert. +- **E-12** ffmpeg `xfade` erfordert ffmpeg >= 4.3. Ältere Versionen fallen auf harten Schnitt zurück. + +--- + +## 15. Verifikation + +| Test | Erwartetes Ergebnis | +|------|---------------------| +| `pip install -e .` | `video-cut --help` zeigt alle Befehle | +| `video-cut cut --input test.mp4 --remove-silence` | Stille-freie MP4 im gleichen Ordner | +| `video-cut cut --input test.mp4 --scene-detect` | Ordner `test_scenes/` mit Szenen-Clips | +| `video-cut music --input test.mp4 --config config.yaml` | MP4 mit Hintergrundmusik aus `resources/music/` | +| `video-cut batch --input ./videos/ --config config.yaml --remove-silence` | Alle Videos in `output/` ohne Stille | +| `video-cut sequence --seq sequence.yaml --config config.yaml` | Finales Video gemäß Timeline in `output/` | +| `video-cut bot --config config.yaml` | Bot startet, registriert Slash-Commands, wartet auf Befehle | +| Discord: `/cut input:/pfad/test.mp4 remove_silence:true` | Bot bestätigt Job, verarbeitet Video, meldet Ergebnis zurück | +| Discord: `/status` | Embed mit aktuellem Job-Status und Warteschlange | +| Discord: `/cancel` | Laufender ffmpeg-Prozess wird beendet, Bestätigung im Kanal | +| `video-cut transcribe --input test.mp4` | SRT-Datei mit Timestamps im gleichen Ordner | +| `video-cut subtitle --input test.mp4` | Video mit eingebrannten Untertiteln | +| `video-cut smart-cut --input test.mp4 --analyze-only` | Aufschlüsselung: Füllwörter, Fehlstarts, Pausen | +| `video-cut smart-cut --input test.mp4` | Video ohne Füllwörter und mit gekürzten Pausen | +| `video-cut chapters --input test.mp4 --format youtube` | YouTube-Kapitel-Beschreibung auf stdout | +| `video-cut highlights --input test.mp4 --duration 60` | 60-Sekunden-Highlight-Video | +| `video-cut merge --inputs a.mp4 b.mp4 -o out.mp4 --crossfade 0.5` | Video mit Crossfade-Übergang | +| `video-cut sequence --seq s.yaml --preview` | Schnelle 360p-Vorschau | +| `video-cut sequence --seq s.yaml --dry-run` | Clip-Liste und Dauer-Schätzung ohne Rendering | diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..0291eeb --- /dev/null +++ b/PLAN.md @@ -0,0 +1,708 @@ +# Implementierungsplan: auto-video-cut Phase 2 + +Stand: 2026-03-20 + +--- + +## Übersicht der Phasen + +``` +Phase 1 ██████████ abgeschlossen + CLI-Grundgerüst, Stille-Entfernung, Szenen-Erkennung, + Musik-Mixing, Text-Overlays, Sequenz-Datei, Batch + +Phase 2a ░░░░░░░░░░ geplant — Video-Qualität + Crossfades, Fade-in/out, Audio-Ducking, Fortschritt, Preview + +Phase 2b ░░░░░░░░░░ geplant — KI-Kern + Whisper-Transkription, Untertitel, Füllwort-Erkennung + +Phase 2c ░░░░░░░░░░ geplant — KI-Erweitert + Auto-Kapitel (LLM), Highlight-Reel, natürlichsprachliche Sequenzen + +Phase 3 ░░░░░░░░░░ geplant — Discord-Bot + bot.py implementieren +``` + +--- + +## Phase 2a: Video-Qualität und UX + +### Neue Dateien + +``` +auto_video_cut/ +├── transitions.py ← Crossfade, Fade-in/out (ffmpeg xfade) +├── ducking.py ← Audio-Ducking (ffmpeg sidechaincompress) +└── progress.py ← Fortschrittsanzeige (rich) +``` + +### Neue Abhängigkeit + +```toml +# pyproject.toml +"rich>=13.0" # Fortschrittsanzeige, Terminal-UI +``` + +--- + +### Schritt 2a-1: Fortschrittsanzeige (`progress.py`) + +**Problem:** Aktuell zeigt das CLI nur Start/Ende. Bei langen Videos wartet man blind. + +**Lösung:** ffmpeg mit `-progress pipe:1` starten und die Ausgabe parsen. + +```python +# progress.py +class FfmpegProgress: + """Parst ffmpeg -progress pipe:1 Ausgabe in Echtzeit.""" + + def __init__(self, total_duration: float): + self.total_duration = total_duration + + def run_with_progress(self, cmd: list[str]) -> subprocess.CompletedProcess: + """ffmpeg-Befehl mit Fortschrittsbalken ausführen.""" + # -progress pipe:1 an cmd anhängen + # stdout zeilenweise lesen + # "out_time_ms=" parsen → Fortschritt berechnen + # rich.progress.Progress aktualisieren +``` + +**Auswirkung auf bestehenden Code:** +- `cutter.py`, `merger.py`, `audio.py`, `text.py`: `_run()` durch `progress.run_with_progress()` ersetzen +- Zentrale `_run()`-Funktion in eigenes Modul auslagern (`runner.py`), damit alle Module sie nutzen + +**Akzeptanzkriterium:** Bei `video-cut cut --input test.mp4 --remove-silence` erscheint ein Fortschrittsbalken mit Prozent und geschätzter Restzeit. + +--- + +### Schritt 2a-2: Crossfade und Fade-in/out (`transitions.py`) + +**Problem:** Harter Schnitt zwischen Clips sieht amateurhaft aus. + +**Lösung:** ffmpeg `xfade` Filter für Übergänge zwischen zwei Clips. + +```python +# transitions.py + +def apply_crossfade( + clip_a: Path, clip_b: Path, output: Path, + duration: float = 0.5, + transition: str = "fade" # fade | dissolve | wipeleft | ... +) -> Path: + """Crossfade zwischen zwei Clips.""" + # ffmpeg -i a.mp4 -i b.mp4 + # -filter_complex "xfade=transition=fade:duration=0.5:offset=" + # output.mp4 + +def apply_fade_in(input: Path, output: Path, duration: float = 0.5) -> Path: + """Fade-in am Clip-Anfang.""" + # ffmpeg -i input -vf "fade=in:d=0.5" -af "afade=in:d=0.5" + +def apply_fade_out(input: Path, output: Path, duration: float = 0.5) -> Path: + """Fade-out am Clip-Ende.""" + # ffmpeg -i input -vf "fade=out:d=0.5:st=" -af "afade=out:d=0.5:st=" +``` + +**Integration in Sequenz-Datei:** +```yaml +sequence: + - type: video + file: "clip1.mp4" + transition: "crossfade" # NEU + transition_duration: 0.5 # NEU + + - type: video + file: "clip2.mp4" + +global: + fade_in: 0.5 # NEU: Fade-in am Anfang des Gesamtvideos + fade_out: 0.5 # NEU: Fade-out am Ende +``` + +**Auswirkung auf bestehenden Code:** +- `merger.py`: `merge_clips()` muss Übergänge zwischen Clips einfügen können. Aktuell concat-demuxer (copy-only) → bei Crossfades muss re-encodet werden. Strategie: paarweises xfade in Pipeline statt eines einzelnen concat. +- `sequencer.py`: `ClipEntry` um `transition` und `transition_duration` erweitern +- `config.py`: Neue Defaults für `transitions` + +**Akzeptanzkriterium:** `video-cut merge --inputs a.mp4 b.mp4 --output merged.mp4 --crossfade 0.5` erzeugt ein Video mit sanftem Übergang. + +--- + +### Schritt 2a-3: Audio-Ducking (`ducking.py`) + +**Problem:** Musik läuft konstant laut, übertönt Sprache oder ist in Pausen zu leise. + +**Lösung:** ffmpeg `sidechaincompress` — Musik wird automatisch leiser wenn der Original-Ton lauter ist. + +```python +# ducking.py + +def apply_ducking( + video: Path, music: Path, output: Path, + volume_original: float = 1.0, + volume_music: float = 0.3, + duck_threshold: float = 0.02, # Ab welcher Lautstärke die Musik leiser wird + duck_ratio: float = 4.0, # Wie stark die Absenkung ist + duck_attack: float = 0.3, # Wie schnell die Musik leiser wird (Sekunden) + duck_release: float = 1.0, # Wie schnell die Musik wieder lauter wird +) -> Path: + """Musik unter Video legen mit automatischem Ducking.""" + # ffmpeg -i video.mp4 -stream_loop -1 -i music.mp3 + # -filter_complex + # "[0:a]volume=[speech]; + # [1:a]volume=[music]; + # [music][speech]sidechaincompress= + # threshold=:ratio=: + # attack=:release=[ducked]; + # [speech][ducked]amix=inputs=2:duration=first[a]" + # -map 0:v -map "[a]" -c:v copy output.mp4 +``` + +**Integration in Config:** +```yaml +music: + ducking: true # NEU: Audio-Ducking aktivieren + duck_threshold: 0.02 # NEU + duck_ratio: 4.0 # NEU + duck_attack: 0.3 # NEU + duck_release: 1.0 # NEU +``` + +**Auswirkung auf bestehenden Code:** +- `audio.py`: `mix_music()` bekommt Parameter `ducking=False`. Wenn aktiv → `ducking.apply_ducking()` statt direktem amix. +- `config.py`: Neue Defaults im `music`-Block + +**Akzeptanzkriterium:** Bei einem Vlog mit Sprache und Musik wird die Musik automatisch leiser während der Sprecher redet und kehrt in Pausen zur konfigurierten Lautstärke zurück. + +--- + +### Schritt 2a-4: Preview und Dry-Run + +**Preview (schnelle Vorschau):** +```bash +video-cut sequence --seq sequence.yaml --preview +``` +- Rendering in 360p mit `-preset ultrafast` +- Datei wird im `/tmp/` abgelegt +- Dauer: ca. 10x schneller als Full-Render + +**Dry-Run (nur Analyse):** +```bash +video-cut sequence --seq sequence.yaml --dry-run +``` +Ausgabe: +``` +Sequenz: 8 Einträge + [1] image title.png 3.0s + [2] video intro.mp4 12.4s + [3] video rohschnitt.mp4 45.2s → Stille entfernen + [4] folder ./aufnahmen/tag1/ 3 Dateien, ~98.0s + ... +Geschätzte Gesamtdauer: 4:12 +Geschätzte Dateigröße: ~180 MB (1080p H.264) +Musik: random aus resources/music/ (3 Dateien verfügbar) +``` + +**Auswirkung auf bestehenden Code:** +- `cli.py`: Neuer Flag `--preview` und `--dry-run` beim `sequence`-Befehl +- `sequencer.py`: Neue Funktion `estimate_sequence()` die Dauer/Größe schätzt ohne zu rendern + +--- + +## Phase 2b: KI-Kern — Whisper + Smart Cutting + +### Neue Dateien + +``` +auto_video_cut/ +├── transcribe.py ← Whisper-Integration (faster-whisper) +├── subtitles.py ← SRT erzeugen und einbrennen +└── smart_cut.py ← Füllwort-Erkennung, intelligentes Schneiden +``` + +### Neue Abhängigkeiten + +```toml +# pyproject.toml — als optionale Gruppe +[project.optional-dependencies] +ai = [ + "faster-whisper>=1.0", + "anthropic>=0.40", # für Phase 2c (Auto-Kapitel) +] +``` + +Installation: `pip install -e ".[ai]"` + +--- + +### Schritt 2b-1: Whisper-Transkription (`transcribe.py`) + +**Kernfunktion:** Audio aus Video extrahieren → Whisper transkribiert → Wort-Level Timestamps. + +```python +# transcribe.py + +@dataclass +class Word: + text: str + start: float # Sekunden + end: float + confidence: float + +@dataclass +class Segment: + text: str + start: float + end: float + words: list[Word] + +def extract_audio(video: Path, output: Path) -> Path: + """Audio-Spur als WAV extrahieren (16kHz Mono für Whisper).""" + # ffmpeg -i video.mp4 -ar 16000 -ac 1 -f wav audio.wav + +def transcribe( + audio: Path, + model_size: str = "base", # tiny | base | small | medium | large-v3 + language: str | None = None, # None = auto-detect + device: str = "auto", # auto | cpu | cuda +) -> list[Segment]: + """Audio mit faster-whisper transkribieren.""" + # from faster_whisper import WhisperModel + # model = WhisperModel(model_size, device=device) + # segments, info = model.transcribe(audio, word_timestamps=True) + # → list[Segment] mit Wort-Level-Timestamps + +def transcribe_video( + video: Path, + model_size: str = "base", + language: str | None = None, +) -> list[Segment]: + """Kompletter Workflow: Video → Audio → Transkript.""" +``` + +**Integration in Config:** +```yaml +ai: + whisper_model: "base" # tiny | base | small | medium | large-v3 + whisper_language: null # null = auto-detect, "de", "en", ... + whisper_device: "auto" # auto | cpu | cuda +``` + +**CLI:** +```bash +video-cut transcribe --input video.mp4 --output untertitel.srt +video-cut transcribe --input video.mp4 --model large-v3 --language de +``` + +**Akzeptanzkriterium:** `video-cut transcribe --input test.mp4` erzeugt eine `.srt`-Datei mit korrekten Timestamps. + +--- + +### Schritt 2b-2: Untertitel erzeugen und einbrennen (`subtitles.py`) + +```python +# subtitles.py + +def segments_to_srt(segments: list[Segment], output: Path) -> Path: + """Transkript-Segmente als SRT-Datei speichern.""" + +def burn_subtitles( + video: Path, srt: Path, output: Path, + font_size: int = 24, + font_color: str = "white", + outline_color: str = "black", + outline_width: int = 2, + position: str = "bottom", # bottom | top +) -> Path: + """Untertitel via ffmpeg subtitles-Filter einbrennen.""" + # ffmpeg -i video.mp4 -vf "subtitles=untertitel.srt:force_style='...'" output.mp4 + +def auto_subtitle( + video: Path, output: Path, + model_size: str = "base", + language: str | None = None, + **style_kwargs, +) -> Path: + """Alles in einem: Transkribieren → SRT → Einbrennen.""" +``` + +**Integration in Sequenz-Datei:** +```yaml +sequence: + - type: video + file: "vlog.mp4" + auto_subtitles: true # NEU + subtitle_language: "de" # NEU (optional) + subtitle_style: # NEU (optional) + font_size: 24 + position: "bottom" +``` + +**CLI:** +```bash +video-cut subtitle --input video.mp4 --output video_sub.mp4 +video-cut subtitle --input video.mp4 --srt existing.srt # vorhandene SRT nutzen +``` + +**Akzeptanzkriterium:** `video-cut subtitle --input test.mp4` erzeugt ein Video mit eingebrannten deutschen Untertiteln. + +--- + +### Schritt 2b-3: Intelligentes Schneiden (`smart_cut.py`) + +**Kernidee:** Whisper liefert Wort-Level-Timestamps. Daraus lässt sich viel mehr machen als nur dB-basierte Stille-Erkennung. + +```python +# smart_cut.py + +# Deutsche und englische Füllwörter +FILLER_WORDS_DE = {"äh", "ähm", "also", "quasi", "sozusagen", "halt", "naja", "ne"} +FILLER_WORDS_EN = {"uh", "um", "like", "you know", "basically", "actually", "so"} + +@dataclass +class CutDecision: + start: float + end: float + reason: str # "silence" | "filler" | "false_start" | "repeat" + confidence: float + +def detect_fillers( + segments: list[Segment], + filler_words: set[str] | None = None, + language: str = "de", +) -> list[CutDecision]: + """Füllwörter im Transkript finden und als Schnitt-Kandidaten markieren.""" + # Jedes Wort prüfen: ist es ein Füllwort? + # Zeitbereich des Worts → CutDecision(reason="filler") + +def detect_false_starts(segments: list[Segment]) -> list[CutDecision]: + """Fehlstarts erkennen: Satz beginnt, bricht ab, beginnt neu.""" + # Heuristik: Segment < 3 Wörter, gefolgt von Pause > 0.3s, + # gefolgt von neuem Segment das ähnlich anfängt + # → CutDecision(reason="false_start") + +def detect_long_pauses( + segments: list[Segment], + max_pause: float = 1.0, + keep_pause: float = 0.3, +) -> list[CutDecision]: + """Pausen zwischen Segmenten erkennen und auf Wunschlänge kürzen.""" + # Pause zwischen Segment N und N+1 > max_pause? + # → Kürzen auf keep_pause Sekunden + +def smart_remove( + video: Path, + output: Path, + model_size: str = "base", + remove_fillers: bool = True, + remove_false_starts: bool = True, + shorten_pauses: bool = True, + max_pause: float = 1.0, + language: str | None = None, +) -> tuple[Path, list[CutDecision]]: + """Intelligenter Schnitt: Transkribieren → Analysieren → Schneiden.""" + # 1. Transkribieren (transcribe.py) + # 2. Füllwörter finden + # 3. Fehlstarts finden + # 4. Pausen analysieren + # 5. Alle CutDecisions zusammenführen + # 6. Inverse Zeitabschnitte berechnen (wie cutter.invert_ranges) + # 7. Clips ausschneiden und zusammenfügen + # Rückgabe: fertiges Video + Liste der Schnitte (für Review) +``` + +**CLI:** +```bash +# Intelligenter Schnitt (ersetzt --remove-silence) +video-cut smart-cut --input video.mp4 +video-cut smart-cut --input video.mp4 --keep-fillers --no-false-starts +video-cut smart-cut --input video.mp4 --max-pause 0.5 + +# Nur Analyse anzeigen, ohne zu schneiden +video-cut smart-cut --input video.mp4 --analyze-only +``` + +Ausgabe `--analyze-only`: +``` +Transkription: 342 Wörter, 2:45 Gesamtdauer +Gefunden: + 12x Füllwörter (äh, ähm, also) → 4.2s einsparen + 3x Fehlstarts → 6.8s einsparen + 8x Pausen > 1.0s (auf 0.3s kürzen) → 9.1s einsparen + ───────────── +Geschätzte Einsparung: 20.1s (12% des Videos) +``` + +**Integration in Sequenz-Datei:** +```yaml +sequence: + - type: video + file: "vlog.mp4" + smart_cut: true # NEU: ersetzt remove_silence + remove_fillers: true # NEU + remove_false_starts: true # NEU + max_pause: 0.8 # NEU +``` + +**Akzeptanzkriterium:** `video-cut smart-cut --input test.mp4 --analyze-only` zeigt eine Aufschlüsselung der erkannten Schnitt-Kandidaten. `video-cut smart-cut --input test.mp4` erzeugt ein Video ohne Füllwörter und gekürzte Pausen. + +--- + +## Phase 2c: KI-Erweitert — LLM-Integration + +### Neue Dateien + +``` +auto_video_cut/ +├── chapters.py ← Auto-Kapitel via LLM +├── highlights.py ← Highlight-Reel +└── describe.py ← Natürlichsprachliche Sequenz-Erstellung +``` + +--- + +### Schritt 2c-1: Auto-Kapitel (`chapters.py`) + +**Workflow:** +``` +Video → Whisper-Transkript → LLM (Claude) → Kapitel mit Titeln +``` + +```python +# chapters.py + +@dataclass +class Chapter: + title: str + start: float + end: float + summary: str + +def generate_chapters( + segments: list[Segment], + llm_provider: str = "anthropic", # anthropic | ollama + model: str = "claude-haiku-4-5-20251001", + language: str = "de", + max_chapters: int = 10, +) -> list[Chapter]: + """Kapitel aus Transkript generieren.""" + # Transkript als Text aufbereiten (mit Timestamps) + # → LLM-Prompt: + # "Du bist ein Video-Editor. Analysiere dieses Transkript + # und erstelle sinnvolle Kapitel mit kurzen, prägnanten Titeln. + # Gib Start-Timestamp und Titel für jedes Kapitel zurück." + # → JSON-Response parsen + +def chapters_to_youtube_format(chapters: list[Chapter]) -> str: + """Kapitel als YouTube-kompatible Beschreibung formatieren.""" + # 0:00 Intro + # 0:45 Ankunft in Berlin + # 3:22 Restaurantbesuch + # ... + +def chapters_to_sequence_entries(chapters: list[Chapter]) -> list[dict]: + """Kapitel als type:text Einträge für sequence.yaml erzeugen.""" + # Für jedes Kapitel einen Text-Clip mit dem Titel generieren +``` + +**CLI:** +```bash +video-cut chapters --input video.mp4 --output chapters.txt +video-cut chapters --input video.mp4 --format youtube +video-cut chapters --input video.mp4 --format sequence # → YAML-Snippet +video-cut chapters --input video.mp4 --inject-titles # Text-Clips einfügen +``` + +**LLM-Konfiguration:** +```yaml +ai: + llm_provider: "anthropic" # anthropic | ollama + llm_model: "claude-haiku-4-5-20251001" + anthropic_api_key: null # oder ANTHROPIC_API_KEY env var + ollama_url: "http://localhost:11434" + ollama_model: "llama3" +``` + +**Akzeptanzkriterium:** `video-cut chapters --input test.mp4 --format youtube` gibt eine YouTube-kompatible Kapitel-Beschreibung aus. + +--- + +### Schritt 2c-2: Highlight-Reel (`highlights.py`) + +**Workflow:** +``` +Video → Transkript + Szenen-Erkennung → LLM bewertet Szenen → Beste auswählen → Zusammenschneiden +``` + +```python +# highlights.py + +@dataclass +class ScoredScene: + start: float + end: float + score: float # 0.0–1.0 + reason: str # Warum diese Szene interessant ist + +def score_scenes( + segments: list[Segment], + scenes: list[TimeRange], + llm_provider: str = "anthropic", +) -> list[ScoredScene]: + """Szenen nach Interesse bewerten.""" + # Für jede Szene: zugehörigen Transkript-Text extrahieren + # LLM bewerten lassen: + # - Enthält Schlüsselaussage? + # - Emotionaler Moment? + # - Neues Thema/Ort? + # - Humor/Überraschung? + +def create_highlight_reel( + video: Path, + output: Path, + target_duration: float = 60.0, # Ziel-Dauer in Sekunden + model_size: str = "base", + crossfade: float = 0.3, +) -> Path: + """Automatisch Highlight-Reel zusammenstellen.""" + # 1. Transkribieren + # 2. Szenen erkennen + # 3. Szenen bewerten + # 4. Beste Szenen auswählen (Rucksack-Problem: maximize score, constrain duration) + # 5. Chronologisch sortieren + # 6. Mit Crossfades zusammenfügen +``` + +**CLI:** +```bash +video-cut highlights --input video.mp4 --duration 60 +video-cut highlights --input video.mp4 --duration 120 --output best_of.mp4 +``` + +--- + +### Schritt 2c-3: Natürlichsprachliche Sequenz (`describe.py`) + +**Workflow:** +``` +Natürlichsprachliche Beschreibung → LLM → sequence.yaml +``` + +```python +# describe.py + +def generate_sequence( + description: str, + available_resources: dict, # Gefundene Dateien in resources/ + available_files: list[Path], # Dateien im angegebenen Ordner + config: dict, +) -> str: + """Aus natürlicher Beschreibung eine sequence.yaml generieren.""" + # LLM-Prompt: + # "Du bist ein Video-Editor. Erstelle eine sequence.yaml + # basierend auf folgender Beschreibung: + # '' + # + # Verfügbare Ressourcen: + # - Musik: + # - Intros: + # - Bilder: + # - Videos im Ordner: + # + # Format der sequence.yaml: " +``` + +**CLI:** +```bash +video-cut describe "Mach ein Reisevlog aus ./berlin/, Stille raus, Intro dran, ruhige Musik" +# → Erzeugt sequence.yaml und zeigt Vorschau + +video-cut describe "..." --execute # Direkt rendern +``` + +--- + +## Aktualisierte Projektstruktur (nach Phase 2) + +``` +auto_video_cut/ +├── __init__.py +├── cli.py ← CLI (erweitert: smart-cut, transcribe, subtitle, chapters, highlights, describe, bot) +├── config.py ← Konfiguration (erweitert: ai-Block, ducking, transitions) +├── runner.py ← NEU: zentrale ffmpeg-Ausführung mit Fortschritt +├── progress.py ← NEU: Fortschrittsanzeige (rich) +├── cutter.py ← Stille/Szenen (unverändert, aber nutzt runner.py) +├── audio.py ← Musik-Mixing (erweitert: Ducking-Option) +├── ducking.py ← NEU: Audio-Ducking +├── merger.py ← Clips zusammenführen (erweitert: Crossfade-Support) +├── transitions.py ← NEU: Crossfade, Fade-in/out +├── text.py ← Text-Overlays (unverändert) +├── sequencer.py ← Sequenz-Datei (erweitert: smart_cut, auto_subtitles, transitions) +├── transcribe.py ← NEU: Whisper-Transkription +├── subtitles.py ← NEU: SRT erzeugen + einbrennen +├── smart_cut.py ← NEU: Füllwort-/Fehlstart-Erkennung +├── chapters.py ← NEU: Auto-Kapitel via LLM +├── highlights.py ← NEU: Highlight-Reel +├── describe.py ← NEU: Natürlichsprachliche Sequenzen +└── bot.py ← NEU: Discord-Bot (Phase 3) +``` + +--- + +## Abhängigkeiten (komplett) + +```toml +[project] +dependencies = [ + "typer>=0.12", + "pyyaml>=6.0", + "scenedetect[opencv]>=0.6", + "ffmpeg-python>=0.2", + "rich>=13.0", + "discord.py>=2.3", +] + +[project.optional-dependencies] +ai = [ + "faster-whisper>=1.0", + "anthropic>=0.40", +] +``` + +- `pip install -e .` → CLI + Discord (ohne KI) +- `pip install -e ".[ai]"` → alles inkl. Whisper + LLM + +--- + +## Implementierungsreihenfolge + +| # | Schritt | Abhängigkeiten | Aufwand | +|---|---------|---------------|---------| +| 1 | `runner.py` + `progress.py` | keine | klein | +| 2 | Bestehende Module auf `runner.py` umstellen | #1 | klein | +| 3 | `transitions.py` (Crossfade, Fade) | #1 | mittel | +| 4 | `merger.py` Crossfade-Integration | #3 | mittel | +| 5 | `ducking.py` + `audio.py` Integration | #1 | mittel | +| 6 | Preview + Dry-Run in `cli.py` | #1 | klein | +| 7 | `transcribe.py` (Whisper) | keine | mittel | +| 8 | `subtitles.py` (SRT + Einbrennen) | #7 | klein | +| 9 | `smart_cut.py` (Füllwörter, Fehlstarts) | #7 | mittel | +| 10 | `chapters.py` (LLM) | #7 | mittel | +| 11 | `highlights.py` (LLM + Szenen) | #7, #10 | groß | +| 12 | `describe.py` (natürlichsprachlich) | #10 | mittel | +| 13 | `bot.py` (Discord) | alle | groß | + +**Empfohlener Start:** #1 → #2 → #7 → #9 → #8 (Fortschritt + Whisper-Kern zuerst) + +--- + +## Risiken + +| Risiko | Auswirkung | Mitigation | +|--------|-----------|------------| +| Whisper-Modell zu langsam auf CPU | KI-Features unbrauchbar | `tiny`/`base` als Default, GPU-Support dokumentieren | +| faster-whisper API-Änderungen | Import-Fehler | Version pinnen, Import-Fallback | +| LLM-API-Kosten (Anthropic) | Unerwartete Kosten bei vielen Videos | Haiku als Default, Kosten-Warnung im CLI | +| ffmpeg xfade-Kompatibilität | Alte ffmpeg-Versionen haben keinen xfade | Versionscheck beim Start, Fallback auf harten Schnitt | +| Füllwort-Erkennung fehlerhaft | "Also" als Satzanfang wird geschnitten | Kontext-Analyse (nur standalone-Füllwörter), `--analyze-only` zum Review | diff --git a/auto_video_cut/__init__.py b/auto_video_cut/__init__.py new file mode 100755 index 0000000..54a7fe7 --- /dev/null +++ b/auto_video_cut/__init__.py @@ -0,0 +1,3 @@ +"""auto-video-cut — Automatisches Video-Schnitt-Tool.""" + +__version__ = "0.1.0" diff --git a/auto_video_cut/audio.py b/auto_video_cut/audio.py new file mode 100755 index 0000000..5032c52 --- /dev/null +++ b/auto_video_cut/audio.py @@ -0,0 +1,116 @@ +"""Hintergrundmusik-Mixing und Audio-Logik.""" + +from __future__ import annotations + +import random +import subprocess +from pathlib import Path + +from .config import MUSIC_EXTENSIONS, get_music_files + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, check=False) + + +def pick_music_file( + music_files: list[Path], + mode: str = "random", +) -> Path: + """Musikdatei nach Modus auswählen.""" + if not music_files: + raise FileNotFoundError("Keine Musikdateien gefunden.") + + if mode == "random": + return random.choice(music_files) + elif mode in ("alphabetical", "loop"): + return sorted(music_files)[0] + else: + raise ValueError(f"Unbekannter Musik-Modus: {mode}") + + +def mix_music( + video_path: Path, + output_path: Path, + music_file: Path, + volume_original: float = 1.0, + volume_music: float = 0.3, +) -> Path: + """Hintergrundmusik in Video mixen.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Prüfen ob Video Audio-Stream hat + probe_cmd = [ + "ffprobe", "-v", "error", + "-select_streams", "a", + "-show_entries", "stream=codec_type", + "-of", "default=noprint_wrappers=1:nokey=1", + str(video_path), + ] + probe = _run(probe_cmd) + has_audio = bool(probe.stdout.strip()) + + if has_audio: + filter_complex = ( + f"[0:a]volume={volume_original}[v1];" + f"[1:a]volume={volume_music}[v2];" + f"[v1][v2]amix=inputs=2:duration=first[a]" + ) + cmd = [ + "ffmpeg", "-y", + "-i", str(video_path), + "-stream_loop", "-1", + "-i", str(music_file), + "-filter_complex", filter_complex, + "-c:v", "copy", + "-c:a", "aac", + "-map", "0:v:0", + "-map", "[a]", + "-shortest", + str(output_path), + ] + else: + # Kein Original-Audio → Musik direkt als Track + cmd = [ + "ffmpeg", "-y", + "-i", str(video_path), + "-stream_loop", "-1", + "-i", str(music_file), + "-filter_complex", f"[1:a]volume={volume_music}[a]", + "-c:v", "copy", + "-c:a", "aac", + "-map", "0:v:0", + "-map", "[a]", + "-shortest", + str(output_path), + ] + + result = _run(cmd) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg Fehler beim Musik-Mixing: {result.stderr}") + + return output_path + + +def add_music_from_config( + video_path: Path, + output_path: Path, + config: dict, +) -> Path: + """Musik aus Konfiguration auswählen und mixen.""" + music_files = get_music_files(config) + if not music_files: + raise FileNotFoundError( + f"Keine Musikdateien in: {config['resources']['folder']}/music/" + ) + + mode = config["music"]["mode"] + music_file = pick_music_file(music_files, mode) + + return mix_music( + video_path=video_path, + output_path=output_path, + music_file=music_file, + volume_original=config["music"]["volume_original"], + volume_music=config["music"]["volume_music"], + ) diff --git a/auto_video_cut/cli.py b/auto_video_cut/cli.py new file mode 100755 index 0000000..88d5f25 --- /dev/null +++ b/auto_video_cut/cli.py @@ -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() diff --git a/auto_video_cut/config.py b/auto_video_cut/config.py new file mode 100755 index 0000000..efa5cb9 --- /dev/null +++ b/auto_video_cut/config.py @@ -0,0 +1,115 @@ +"""Konfiguration laden und validieren.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import yaml + +MUSIC_EXTENSIONS = {".mp3", ".wav", ".flac", ".aac", ".ogg"} +VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv"} +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg"} + +DEFAULTS: dict[str, Any] = { + "resources": { + "folder": "./resources", + }, + "music": { + "mode": "random", + "volume_original": 1.0, + "volume_music": 0.3, + }, + "videos": { + "intro": None, + "outro": None, + "transitions": False, + }, + "images": { + "title_card": None, + "duration": 3, + }, + "silence": { + "threshold_db": -40, + "min_duration": 0.5, + }, + "scenes": { + "threshold": 27.0, + }, + "output": { + "format": "mp4", + "folder": "./output", + }, +} + + +def _deep_merge(base: dict, override: dict) -> dict: + """Rekursiv Dictionaries zusammenführen.""" + result = dict(base) + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = _deep_merge(result[key], value) + else: + result[key] = value + return result + + +def load_config(config_path: str | Path | None = None) -> dict[str, Any]: + """YAML-Konfiguration laden und mit Standardwerten zusammenführen.""" + config = dict(DEFAULTS) + if config_path is None: + return config + + path = Path(config_path) + if not path.exists(): + raise FileNotFoundError(f"Konfigurationsdatei nicht gefunden: {path}") + + with open(path, encoding="utf-8") as fh: + user_config = yaml.safe_load(fh) or {} + + return _deep_merge(config, user_config) + + +def validate_config(config: dict[str, Any]) -> list[str]: + """Konfiguration prüfen, Warnungen zurückgeben.""" + warnings: list[str] = [] + + resources_folder = Path(config["resources"]["folder"]) + if not resources_folder.exists(): + warnings.append(f"Ressourcen-Ordner existiert nicht: {resources_folder}") + else: + music_folder = resources_folder / "music" + if not music_folder.exists(): + warnings.append(f"Musik-Ordner existiert nicht: {music_folder}") + else: + music_files = [ + f for f in music_folder.iterdir() + if f.suffix.lower() in MUSIC_EXTENSIONS + ] + if not music_files: + warnings.append(f"Keine Musikdateien in: {music_folder}") + + vol_orig = config["music"]["volume_original"] + vol_music = config["music"]["volume_music"] + if not (0.0 <= vol_orig <= 1.0): + warnings.append(f"volume_original muss zwischen 0.0 und 1.0 liegen (ist: {vol_orig})") + if not (0.0 <= vol_music <= 1.0): + warnings.append(f"volume_music muss zwischen 0.0 und 1.0 liegen (ist: {vol_music})") + + return warnings + + +def get_resources_folder(config: dict[str, Any]) -> Path: + return Path(config["resources"]["folder"]) + + +def get_music_files(config: dict[str, Any]) -> list[Path]: + """Alle Musikdateien aus dem konfigurierten Ordner zurückgeben.""" + music_folder = get_resources_folder(config) / "music" + if not music_folder.exists(): + return [] + return sorted( + f for f in music_folder.iterdir() + if f.suffix.lower() in MUSIC_EXTENSIONS + ) diff --git a/auto_video_cut/cutter.py b/auto_video_cut/cutter.py new file mode 100755 index 0000000..3ec46de --- /dev/null +++ b/auto_video_cut/cutter.py @@ -0,0 +1,198 @@ +"""Stille- und Szenen-Erkennung sowie Schnitt-Logik.""" + +from __future__ import annotations + +import re +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class TimeRange: + start: float + end: float + + @property + def duration(self) -> float: + return self.end - self.start + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, check=False) + + +# --------------------------------------------------------------------------- +# Stille-Erkennung +# --------------------------------------------------------------------------- + +def detect_silence( + input_path: Path, + threshold_db: float = -40, + min_duration: float = 0.5, +) -> list[TimeRange]: + """Stille-Abschnitte via ffmpeg silencedetect erkennen.""" + cmd = [ + "ffmpeg", "-i", str(input_path), + "-af", f"silencedetect=n={threshold_db}dB:d={min_duration}", + "-f", "null", "-", + ] + result = _run(cmd) + output = result.stderr + + starts = [float(m) for m in re.findall(r"silence_start: ([\d.]+)", output)] + ends = [float(m) for m in re.findall(r"silence_end: ([\d.]+)", output)] + + return [TimeRange(s, e) for s, e in zip(starts, ends)] + + +def _get_duration(input_path: Path) -> float: + """Video-Länge in Sekunden ermitteln.""" + cmd = [ + "ffprobe", "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + str(input_path), + ] + result = _run(cmd) + return float(result.stdout.strip()) + + +def invert_ranges(silence_ranges: list[TimeRange], total_duration: float) -> list[TimeRange]: + """Stille-Abschnitte umkehren → Abschnitte mit Ton.""" + speech: list[TimeRange] = [] + cursor = 0.0 + for silence in sorted(silence_ranges, key=lambda r: r.start): + if silence.start > cursor: + speech.append(TimeRange(cursor, silence.start)) + cursor = silence.end + if cursor < total_duration: + speech.append(TimeRange(cursor, total_duration)) + return speech + + +def remove_silence( + input_path: Path, + output_path: Path, + threshold_db: float = -40, + min_duration: float = 0.5, +) -> Path: + """Stille aus Video entfernen und Ergebnis speichern.""" + silence = detect_silence(input_path, threshold_db, min_duration) + total = _get_duration(input_path) + segments = invert_ranges(silence, total) + + if not segments: + raise RuntimeError("Keine Ton-Abschnitte gefunden — Stille-Schwelle zu hoch?") + + output_path.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp = Path(tmp_dir) + clip_files: list[Path] = [] + + for i, seg in enumerate(segments): + clip = tmp / f"seg_{i:04d}.mp4" + cmd = [ + "ffmpeg", "-y", + "-ss", str(seg.start), + "-to", str(seg.end), + "-i", str(input_path), + "-c", "copy", + str(clip), + ] + result = _run(cmd) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg Fehler beim Ausschneiden: {result.stderr}") + clip_files.append(clip) + + _concat_clips(clip_files, output_path) + + return output_path + + +# --------------------------------------------------------------------------- +# Szenen-Erkennung +# --------------------------------------------------------------------------- + +def detect_scenes(input_path: Path, threshold: float = 27.0) -> list[TimeRange]: + """Szenen-Grenzen via PySceneDetect erkennen.""" + try: + from scenedetect import VideoManager, SceneManager + from scenedetect.detectors import ContentDetector + except ImportError: + raise ImportError("PySceneDetect nicht installiert: pip install scenedetect[opencv]") + + video_manager = VideoManager([str(input_path)]) + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector(threshold=threshold)) + + video_manager.set_downscale_factor() + video_manager.start() + scene_manager.detect_scenes(frame_source=video_manager) + scene_list = scene_manager.get_scene_list() + video_manager.release() + + return [ + TimeRange( + start.get_seconds(), + end.get_seconds(), + ) + for start, end in scene_list + ] + + +def split_scenes( + input_path: Path, + output_folder: Path, + threshold: float = 27.0, +) -> list[Path]: + """Video an Szenen-Grenzen aufteilen.""" + scenes = detect_scenes(input_path, threshold) + output_folder.mkdir(parents=True, exist_ok=True) + clips: list[Path] = [] + + for i, scene in enumerate(scenes): + out = output_folder / f"scene_{i:04d}.mp4" + cmd = [ + "ffmpeg", "-y", + "-ss", str(scene.start), + "-to", str(scene.end), + "-i", str(input_path), + "-c", "copy", + str(out), + ] + result = _run(cmd) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg Fehler: {result.stderr}") + clips.append(out) + + return clips + + +# --------------------------------------------------------------------------- +# Hilfsfunktion concat +# --------------------------------------------------------------------------- + +def _concat_clips(clips: list[Path], output: Path) -> None: + """Clips via ffmpeg concat demuxer zusammenfügen.""" + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", delete=False, encoding="utf-8" + ) as fh: + list_file = Path(fh.name) + for clip in clips: + fh.write(f"file '{clip.resolve()}'\n") + + cmd = [ + "ffmpeg", "-y", + "-f", "concat", "-safe", "0", + "-i", str(list_file), + "-c", "copy", + str(output), + ] + result = _run(cmd) + list_file.unlink(missing_ok=True) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg concat Fehler: {result.stderr}") diff --git a/auto_video_cut/merger.py b/auto_video_cut/merger.py new file mode 100755 index 0000000..b9371ba --- /dev/null +++ b/auto_video_cut/merger.py @@ -0,0 +1,117 @@ +"""Clips zusammenführen via ffmpeg concat.""" + +from __future__ import annotations + +import subprocess +import tempfile +from pathlib import Path + +from .config import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, check=False) + + +def image_to_clip( + image_path: Path, + output_path: Path, + duration: float = 3.0, + width: int = 1920, + height: int = 1080, +) -> Path: + """Standbild in Video-Clip konvertieren.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + cmd = [ + "ffmpeg", "-y", + "-loop", "1", + "-i", str(image_path), + "-t", str(duration), + "-vf", f"scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2", + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-r", "25", + str(output_path), + ] + result = _run(cmd) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg image-to-clip Fehler: {result.stderr}") + return output_path + + +def normalize_clip(input_path: Path, output_path: Path) -> Path: + """Clip auf einheitliches Format re-encoden (für concat-Kompatibilität).""" + output_path.parent.mkdir(parents=True, exist_ok=True) + cmd = [ + "ffmpeg", "-y", + "-i", str(input_path), + "-c:v", "libx264", + "-c:a", "aac", + "-pix_fmt", "yuv420p", + "-r", "25", + str(output_path), + ] + result = _run(cmd) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg normalize Fehler: {result.stderr}") + return output_path + + +def merge_clips( + clips: list[Path], + output_path: Path, + intro: Path | None = None, + outro: Path | None = None, + normalize: bool = True, +) -> Path: + """Clips zusammenführen, optional mit Intro und Outro.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + + all_clips: list[Path] = [] + if intro: + all_clips.append(intro) + all_clips.extend(clips) + if outro: + all_clips.append(outro) + + if not all_clips: + raise ValueError("Keine Clips zum Zusammenführen.") + + if len(all_clips) == 1: + if normalize: + return normalize_clip(all_clips[0], output_path) + cmd = ["ffmpeg", "-y", "-i", str(all_clips[0]), "-c", "copy", str(output_path)] + result = _run(cmd) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg copy Fehler: {result.stderr}") + return output_path + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp = Path(tmp_dir) + + if normalize: + ready_clips = [ + normalize_clip(clip, tmp / f"norm_{i:04d}.mp4") + for i, clip in enumerate(all_clips) + ] + else: + ready_clips = all_clips + + list_file = tmp / "clips.txt" + with open(list_file, "w", encoding="utf-8") as fh: + for clip in ready_clips: + fh.write(f"file '{clip.resolve()}'\n") + + cmd = [ + "ffmpeg", "-y", + "-f", "concat", "-safe", "0", + "-i", str(list_file), + "-c", "copy", + str(output_path), + ] + result = _run(cmd) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg concat Fehler: {result.stderr}") + + return output_path diff --git a/auto_video_cut/sequencer.py b/auto_video_cut/sequencer.py new file mode 100755 index 0000000..2281fec --- /dev/null +++ b/auto_video_cut/sequencer.py @@ -0,0 +1,149 @@ +"""sequence.yaml parsen und geordnete Clip-Liste aufbauen.""" + +from __future__ import annotations + +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +from .config import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS + + +@dataclass +class ClipEntry: + """Ein aufgelöster Clip mit Metadaten.""" + path: Path + media_type: str # "video" | "image" + remove_silence: bool = False + trim_silence: bool = False + overlay_text: str | None = None + overlay_position: str = "bottom" + overlay_duration: float | None = None + image_duration: float = 3.0 + + +def _resolve_media_type(path: Path) -> str: + ext = path.suffix.lower() + if ext in VIDEO_EXTENSIONS: + return "video" + if ext in IMAGE_EXTENSIONS: + return "image" + raise ValueError(f"Unbekannter Dateityp: {path}") + + +def _sort_paths(paths: list[Path], sort: str) -> list[Path]: + if sort == "alphabetical": + return sorted(paths, key=lambda p: p.name.lower()) + elif sort == "date": + return sorted(paths, key=lambda p: p.stat().st_mtime) + else: + return paths + + +def _expand_folder(entry: dict[str, Any], default_image_duration: float) -> list[ClipEntry]: + """Ordner-Eintrag in einzelne Clips auflösen.""" + folder = Path(entry["path"]) + if not folder.exists(): + raise FileNotFoundError(f"Ordner nicht gefunden: {folder}") + + all_extensions = VIDEO_EXTENSIONS | IMAGE_EXTENSIONS + files = [f for f in folder.iterdir() if f.suffix.lower() in all_extensions] + files = _sort_paths(files, entry.get("sort", "alphabetical")) + + return [ + ClipEntry( + path=f, + media_type=_resolve_media_type(f), + remove_silence=entry.get("remove_silence", False), + trim_silence=entry.get("trim_silence", False), + image_duration=entry.get("image_duration", default_image_duration), + ) + for f in files + ] + + +def parse_sequence( + sequence_path: Path, + resources_folder: Path | None = None, + default_image_duration: float = 3.0, +) -> tuple[list[ClipEntry], dict[str, Any]]: + """ + sequence.yaml einlesen. + + Gibt zurück: + clips — geordnete Liste von ClipEntry-Objekten + music_cfg — Musik-Konfiguration aus der Sequenz-Datei (oder leer) + """ + if not sequence_path.exists(): + raise FileNotFoundError(f"Sequenz-Datei nicht gefunden: {sequence_path}") + + with open(sequence_path, encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + + clips: list[ClipEntry] = [] + + for entry in data.get("sequence", []): + entry_type = entry.get("type") + + if entry_type == "video": + raw_file = entry.get("file", "") + path = _resolve_path(raw_file, resources_folder, "videos") + clips.append(ClipEntry( + path=path, + media_type="video", + remove_silence=entry.get("remove_silence", False), + trim_silence=entry.get("trim_silence", False), + overlay_text=entry.get("overlay_text"), + overlay_position=entry.get("overlay_position", "bottom"), + overlay_duration=entry.get("overlay_duration"), + )) + + elif entry_type == "image": + raw_file = entry.get("file", "") + path = _resolve_path(raw_file, resources_folder, "images") + clips.append(ClipEntry( + path=path, + media_type="image", + image_duration=float(entry.get("duration", default_image_duration)), + )) + + elif entry_type == "text": + # Text-Clips werden als Platzhalter gespeichert; + # merger.py erzeugt den echten Clip später. + clips.append(ClipEntry( + path=Path("__text__"), + media_type="text", + image_duration=float(entry.get("duration", default_image_duration)), + overlay_text=entry.get("content", ""), + overlay_position=entry.get("style", {}).get("position", "center"), + )) + # Stil-Infos direkt am Entry speichern + clips[-1].__dict__["text_style"] = entry.get("style", {}) + + elif entry_type == "folder": + clips.extend(_expand_folder(entry, default_image_duration)) + + else: + raise ValueError(f"Unbekannter Sequenz-Typ: {entry_type!r}") + + music_cfg = data.get("music", {}) + return clips, music_cfg + + +def _resolve_path( + raw: str, + resources_folder: Path | None, + subfolder: str, +) -> Path: + """Datei-Pfad auflösen: absolut, relativ oder aus resources/.""" + path = Path(raw) + if path.is_absolute() or path.exists(): + return path + if resources_folder: + candidate = resources_folder / subfolder / raw + if candidate.exists(): + return candidate + return path # Existenz-Prüfung erfolgt später diff --git a/auto_video_cut/text.py b/auto_video_cut/text.py new file mode 100755 index 0000000..10cfa04 --- /dev/null +++ b/auto_video_cut/text.py @@ -0,0 +1,105 @@ +"""Text-Einblendungen und Overlays via ffmpeg drawtext.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, check=False) + + +_POSITION_MAP = { + "center": ("(w-text_w)/2", "(h-text_h)/2"), + "top": ("(w-text_w)/2", "50"), + "bottom": ("(w-text_w)/2", "h-text_h-50"), +} + + +def create_text_clip( + output_path: Path, + content: str, + duration: float = 3.0, + font_size: int = 72, + font_color: str = "white", + background_color: str = "black", + position: str = "center", + width: int = 1920, + height: int = 1080, +) -> Path: + """Text-Standbild-Clip erzeugen (schwarzer/farbiger Hintergrund).""" + output_path.parent.mkdir(parents=True, exist_ok=True) + + x_expr, y_expr = _POSITION_MAP.get(position, _POSITION_MAP["center"]) + + # Hintergrund-Farbe: "transparent" → schwarzer Hintergrund mit alpha + bg = "black" if background_color == "transparent" else background_color + + drawtext = ( + f"drawtext=text='{_escape_text(content)}':" + f"fontsize={font_size}:" + f"fontcolor={font_color}:" + f"x={x_expr}:y={y_expr}" + ) + + cmd = [ + "ffmpeg", "-y", + "-f", "lavfi", + "-i", f"color=c={bg}:size={width}x{height}:rate=25:duration={duration}", + "-vf", drawtext, + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + str(output_path), + ] + result = _run(cmd) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg drawtext Fehler: {result.stderr}") + + return output_path + + +def add_text_overlay( + input_path: Path, + output_path: Path, + text: str, + position: str = "bottom", + duration: float | None = None, + font_size: int = 48, + font_color: str = "white", +) -> Path: + """Text-Overlay auf ein laufendes Video legen.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + + x_expr, y_expr = _POSITION_MAP.get(position, _POSITION_MAP["bottom"]) + + if duration is not None: + enable = f"enable='between(t,0,{duration})'" + else: + enable = "enable=1" + + drawtext = ( + f"drawtext=text='{_escape_text(text)}':" + f"fontsize={font_size}:" + f"fontcolor={font_color}:" + f"x={x_expr}:y={y_expr}:" + f"{enable}" + ) + + cmd = [ + "ffmpeg", "-y", + "-i", str(input_path), + "-vf", drawtext, + "-c:a", "copy", + str(output_path), + ] + result = _run(cmd) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg overlay Fehler: {result.stderr}") + + return output_path + + +def _escape_text(text: str) -> str: + """Sonderzeichen für ffmpeg drawtext escapen.""" + return text.replace("'", "\\'").replace(":", "\\:").replace("\\", "\\\\") diff --git a/config.example.yaml b/config.example.yaml new file mode 100755 index 0000000..b6c9d96 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,27 @@ +resources: + folder: "/pfad/zu/resources" # Basis-Ordner für alle Ressourcen + +music: + mode: "random" # random | alphabetical | loop + volume_original: 1.0 # Lautstärke Original-Ton (0.0–1.0) + volume_music: 0.3 # Lautstärke Hintergrundmusik (0.0–1.0) + +videos: + intro: "intro.mp4" # Dateiname aus resources/videos/ (optional) + outro: "outro.mp4" # Dateiname aus resources/videos/ (optional) + transitions: false # Übergangs-Clips automatisch zwischen Szenen einsetzen + +images: + title_card: "title.png" # Titelkarte am Anfang einblenden (optional) + duration: 3 # Einblenddauer in Sekunden + +silence: + threshold_db: -40 # Stille-Schwelle in dB + min_duration: 0.5 # Minimale Stille-Dauer in Sekunden + +scenes: + threshold: 27.0 # Szenen-Erkennungs-Schwelle + +output: + format: "mp4" # mp4 | webm + folder: "./output" diff --git a/pyproject.toml b/pyproject.toml new file mode 100755 index 0000000..68fd178 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "auto-video-cut" +version = "0.1.0" +description = "Automatisches Video-Schnitt-Tool für die Kommandozeile" +requires-python = ">=3.10" +dependencies = [ + "typer>=0.12", + "pyyaml>=6.0", + "scenedetect[opencv]>=0.6", + "ffmpeg-python>=0.2", +] + +[project.scripts] +video-cut = "auto_video_cut.cli:app" + +[tool.setuptools.packages.find] +where = ["."] +include = ["auto_video_cut*"] diff --git a/sequence.example.yaml b/sequence.example.yaml new file mode 100755 index 0000000..023b259 --- /dev/null +++ b/sequence.example.yaml @@ -0,0 +1,51 @@ +# sequence.example.yaml — Definiert Reihenfolge und Verarbeitung aller Materialien +output: "mein_video.mp4" + +sequence: + - type: image + file: "title.png" # aus resources/images/ + duration: 3 # Sekunden + + - type: video + file: "intro.mp4" # aus resources/videos/ + + - type: video + file: "rohschnitt.mp4" # einzelne Datei (beliebiger Pfad) + remove_silence: true + + - type: folder # ALLE Dateien im Ordner in Reihenfolge + path: "./aufnahmen/tag1/" + sort: "alphabetical" # alphabetical | date + remove_silence: true + + - type: folder + path: "./aufnahmen/tag2/" + sort: "date" + trim_silence: true + + - type: image + file: "slide_ende.png" + duration: 2 + + - type: text # Text-Einblendung als Standbild-Clip + content: "Kapitel 2" + duration: 3 + style: + font_size: 72 + font_color: "white" + background_color: "black" + position: "center" # center | top | bottom + + - type: video + file: "aufnahme.mp4" + overlay_text: "Berlin, März 2026" # Text über laufendes Video + overlay_position: "bottom" + overlay_duration: 4 # Sekunden ab Clip-Anfang + + - type: video + file: "outro.mp4" # aus resources/videos/ + +music: + file: "random" # "random" = zufällig aus resources/music/ + volume_original: 1.0 + volume_music: 0.25 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..5283932 --- /dev/null +++ b/setup.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# setup.sh — Abhängigkeiten für auto-video-cut installieren (Linux) +set -euo pipefail + +echo "=== auto-video-cut Setup ===" +echo "" + +# --- ffmpeg prüfen / installieren --- +if ! command -v ffmpeg &>/dev/null; then + echo "[1/3] ffmpeg nicht gefunden — installiere via apt..." + sudo apt-get update -qq + sudo apt-get install -y ffmpeg +else + echo "[1/3] ffmpeg bereits installiert: $(ffmpeg -version 2>&1 | head -1)" +fi + +# --- Python prüfen --- +echo "" +PYTHON="" +for candidate in python3 python; do + if command -v "$candidate" &>/dev/null; then + version=$("$candidate" -c "import sys; print(sys.version_info[:2])") + if "$candidate" -c "import sys; sys.exit(0 if sys.version_info >= (3,10) else 1)" 2>/dev/null; then + PYTHON="$candidate" + echo "[2/3] Python gefunden: $("$candidate" --version)" + break + fi + fi +done + +if [ -z "$PYTHON" ]; then + echo "[2/3] Python >= 3.10 nicht gefunden — installiere via apt..." + sudo apt-get update -qq + sudo apt-get install -y python3 python3-pip python3-venv + PYTHON=python3 +fi + +# --- Virtuelle Umgebung --- +echo "" +echo "[3/3] Python-Paket installieren..." + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ ! -d "$SCRIPT_DIR/.venv" ]; then + echo " Erstelle virtuelle Umgebung in .venv/ ..." + "$PYTHON" -m venv "$SCRIPT_DIR/.venv" +fi + +# venv aktivieren +source "$SCRIPT_DIR/.venv/bin/activate" + +pip install --upgrade pip -q +pip install -e "$SCRIPT_DIR" -q + +echo "" +echo "=== Fertig ===" +echo "" +echo "Starten:" +echo " source .venv/bin/activate" +echo " video-cut --help" +echo "" +echo "Oder direkt (ohne Aktivierung):" +echo " .venv/bin/video-cut --help"