Zum Inhalt

pipeline_core.py

"""Shared helpers used by both the TUI and the WebGUI.

Pure-Python module with no UI dependencies (no textual, no fastapi).
Owns the pipeline-config dataclass, command builder, audio-path resolution,
diarization-availability check, and the stdout-line classifier.
"""

import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path


# ── Pipeline-config dataclass ─────────────────────────────────────────────
@dataclass
class PipelineConfig:
    audio: str
    viz: str
    language: str
    model: str
    diarize: str  # "auto" | "off" | "2" | "3" | ...
    episode: str
    show_name: str
    skip_transcribe: bool
    skip_meta: bool
    skip_render: bool
    skip_upload: bool


# ── Diarization availability ──────────────────────────────────────────────
def is_pyannote_cached() -> bool:
    hf_home = os.environ.get("HF_HOME", os.path.expanduser("~/.cache/huggingface"))
    return os.path.isdir(os.path.join(hf_home, "hub", "models--pyannote--speaker-diarization-3.1"))


def can_diarize() -> bool:
    return bool(os.environ.get("HF_TOKEN")) or is_pyannote_cached()


# ── Audio-path resolution ─────────────────────────────────────────────────
def resolve_audio_path(audio: str, fallback_dir: Path) -> Path:
    """Expand ~, make absolute. CWD wins; fallback_dir tried if CWD-relative fails."""
    p = Path(audio).expanduser()
    if p.is_absolute():
        return p
    cwd_resolved = (Path.cwd() / p).resolve()
    if cwd_resolved.exists():
        return cwd_resolved
    return (fallback_dir / p).resolve()


# ── Command builder ───────────────────────────────────────────────────────
def build_command(config: PipelineConfig, pipeline_dir: Path) -> list[str]:
    cmd = [
        sys.executable,
        str(pipeline_dir / "pipeline.py"),
        config.audio,
        "--viz",
        config.viz,
        "--language",
        config.language,
        "--model",
        config.model,
        "--episode",
        config.episode,
        "--show-name",
        config.show_name,
    ]
    if config.diarize == "off":
        cmd.append("--no-diarize")
    elif config.diarize.isdigit():
        cmd.extend(["--speakers", config.diarize])
    if config.skip_transcribe:
        cmd.append("--skip-transcribe")
    if config.skip_meta:
        cmd.append("--skip-meta")
    if config.skip_render:
        cmd.append("--skip-render")
    if config.skip_upload:
        cmd.append("--skip-upload")
    return cmd


# ── Progress event dataclass ──────────────────────────────────────────────
@dataclass
class ProgressEvent:
    progress: float  # 0–100
    label: str  # display label
    step: int  # 1–4


# ── Stdout-line classifier ────────────────────────────────────────────────
_STEP_MARKERS = [
    (re.compile(r"SCHRITT 1:"), 1, "Schritt 1/4 · Transkription", 2),
    (re.compile(r"\[1/4\] Modell"), 1, "Schritt 1/4 · Modell laden …", 5),
    (re.compile(r"\[2/4\] Transkrib"), 1, "Schritt 1/4 · Transkribieren …", 15),
    (re.compile(r"\[3/4\] Wort"), 1, "Schritt 1/4 · Wort-Alignment …", 32),
    (re.compile(r"\[4/4\]"), 1, "Schritt 1/4 · Speaker-Erkennung …", 39),
    (re.compile(r"SCHRITT 2:"), 2, "Schritt 2/4 · Metadaten generieren …", 44),
    (re.compile(r"Metadaten generieren via"), 2, "Schritt 2/4 · LLM generiert …", 47),
    (re.compile(r"SCHRITT 3:"), 3, "Schritt 3/4 · Video rendern …", 53),
    (re.compile(r"SCHRITT 4:"), 4, "Schritt 4/4 · YouTube-Upload …", 96),
]
_RENDER_PCT_RE = re.compile(r"Rendering\s+(\d+\.?\d*)%")


def match_line(line: str, current_step: int) -> ProgressEvent | None:
    """Map a single stdout line to a progress event, or None if it doesn't match."""
    for pattern, step, label, progress in _STEP_MARKERS:
        if pattern.search(line):
            return ProgressEvent(progress, label, step)
    if current_step == 3:
        m = _RENDER_PCT_RE.search(line)
        if m:
            pct = float(m.group(1))
            overall = 53.0 + pct * 42.0 / 100.0
            return ProgressEvent(overall, f"Schritt 3/4 · Rendering {pct:.0f}%", 3)
    return None