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