Initial commit: auto-video-cut project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
17
.gitignore
vendored
Executable file
17
.gitignore
vendored
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
output/
|
||||||
|
*.mp4
|
||||||
|
*.mov
|
||||||
|
*.avi
|
||||||
|
*.mkv
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.flac
|
||||||
|
*.aac
|
||||||
|
*.ogg
|
||||||
607
ANFORDERUNGEN.md
Executable file
607
ANFORDERUNGEN.md
Executable file
@@ -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: `<stem>_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: `<stem>_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:<Pfad> [remove_silence:true|false] [scene_detect:true|false] [config:<Pfad>]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Schneidet ein Video (Stille entfernen und/oder Szenen aufteilen)
|
||||||
|
- Antwort: Fortschritts-Embed → Abschluss-Embed mit Ausgabepfad
|
||||||
|
|
||||||
|
#### `/merge`
|
||||||
|
|
||||||
|
```
|
||||||
|
/merge inputs:<Pfad1,Pfad2,...> output:<Pfad> [intro:<Pfad>] [outro:<Pfad>]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Fügt mehrere Clips zusammen
|
||||||
|
|
||||||
|
#### `/music`
|
||||||
|
|
||||||
|
```
|
||||||
|
/music input:<Pfad> [config:<Pfad>] [music_file:<Pfad>] [vol_orig:1.0] [vol_music:0.3]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Legt Hintergrundmusik unter ein Video
|
||||||
|
|
||||||
|
#### `/batch`
|
||||||
|
|
||||||
|
```
|
||||||
|
/batch input:<Ordner> [config:<Pfad>] [remove_silence:true] [music:true]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Verarbeitet alle Videos in einem Ordner
|
||||||
|
|
||||||
|
#### `/sequence`
|
||||||
|
|
||||||
|
```
|
||||||
|
/sequence seq:<Pfad> [config:<Pfad>] [output:<Pfad>] [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:<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 |
|
||||||
708
PLAN.md
Normal file
708
PLAN.md
Normal file
@@ -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=<a_dur-0.5>"
|
||||||
|
# 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=<dur-0.5>" -af "afade=out:d=0.5:st=<dur-0.5>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**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=<orig>[speech];
|
||||||
|
# [1:a]volume=<music>[music];
|
||||||
|
# [music][speech]sidechaincompress=
|
||||||
|
# threshold=<thresh>:ratio=<ratio>:
|
||||||
|
# attack=<attack>:release=<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:
|
||||||
|
# '<description>'
|
||||||
|
#
|
||||||
|
# Verfügbare Ressourcen:
|
||||||
|
# - Musik: <liste>
|
||||||
|
# - Intros: <liste>
|
||||||
|
# - Bilder: <liste>
|
||||||
|
# - Videos im Ordner: <liste>
|
||||||
|
#
|
||||||
|
# Format der sequence.yaml: <schema>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 |
|
||||||
3
auto_video_cut/__init__.py
Executable file
3
auto_video_cut/__init__.py
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
"""auto-video-cut — Automatisches Video-Schnitt-Tool."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
116
auto_video_cut/audio.py
Executable file
116
auto_video_cut/audio.py
Executable file
@@ -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"],
|
||||||
|
)
|
||||||
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()
|
||||||
115
auto_video_cut/config.py
Executable file
115
auto_video_cut/config.py
Executable file
@@ -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
|
||||||
|
)
|
||||||
198
auto_video_cut/cutter.py
Executable file
198
auto_video_cut/cutter.py
Executable file
@@ -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}")
|
||||||
117
auto_video_cut/merger.py
Executable file
117
auto_video_cut/merger.py
Executable file
@@ -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
|
||||||
149
auto_video_cut/sequencer.py
Executable file
149
auto_video_cut/sequencer.py
Executable file
@@ -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
|
||||||
105
auto_video_cut/text.py
Executable file
105
auto_video_cut/text.py
Executable file
@@ -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("\\", "\\\\")
|
||||||
27
config.example.yaml
Executable file
27
config.example.yaml
Executable file
@@ -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"
|
||||||
22
pyproject.toml
Executable file
22
pyproject.toml
Executable file
@@ -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*"]
|
||||||
51
sequence.example.yaml
Executable file
51
sequence.example.yaml
Executable file
@@ -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
|
||||||
63
setup.sh
Executable file
63
setup.sh
Executable file
@@ -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"
|
||||||
Reference in New Issue
Block a user