Initial commit: auto-video-cut project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-04-06 21:51:01 +02:00
commit 267070ad52
15 changed files with 2635 additions and 0 deletions

149
auto_video_cut/sequencer.py Executable file
View 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