#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
clip_extractor.py — автоматически нарезает YouTube видео на вирусные шортсы.

Пайплайн:
  1. yt-dlp скачивает видео (лучшее качество MP4)
  2. Whisper (локально, бесплатно) транскрибирует с таймкодами
  3. Claude/LLM анализирует текст → выбирает 3-5 цепляющих момента
  4. ffmpeg вырезает клипы, кадрирует 9:16, сжигает субтитры
  5. ffmpeg накладывает фоновую музыку (-20dB)

Usage:
    python clip_extractor.py <youtube_url>
    python clip_extractor.py <youtube_url> --clips 5 --lang ru
    python clip_extractor.py <youtube_url> --music path/to/track.mp3
    python clip_extractor.py <youtube_url> --whisper-model medium

Models: tiny (fastest), base (default), small, medium (best quality)
"""

import sys, os, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')

import argparse
import json
import re
import subprocess
import tempfile
from pathlib import Path

# Load .env
_here = Path(__file__).resolve()
for _p in [_here.parent, _here.parent.parent]:
    _env = _p / ".env"
    if _env.exists():
        for line in _env.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                k, _, v = line.partition("=")
                k, v = k.strip(), v.strip().strip('"').strip("'")
                if k and v and k not in os.environ:
                    os.environ[k] = v
        break

SCRIPT_DIR = Path(__file__).parent
MUSIC_DIR = SCRIPT_DIR / "music"


def get_workspace_dir() -> Path:
    raw_value = os.environ.get("WORKSPACE_DIR") or os.environ.get("MEDIA_SKILL_WORKDIR")
    return Path(raw_value) if raw_value else SCRIPT_DIR.parent


def get_output_dir() -> Path:
    raw_value = os.environ.get("MEDIA_SKILL_OUTPUT_DIR")
    if raw_value:
        return Path(raw_value) / "clips"
    return get_workspace_dir() / "output" / "clips"


OUTPUT_DIR = get_output_dir()

GREEN  = "\033[92m"
YELLOW = "\033[93m"
RED    = "\033[91m"
RESET  = "\033[0m"

def log(msg, color=GREEN):    print(f"{color}{msg}{RESET}", flush=True)
def warn(msg):                print(f"{YELLOW}⚠ {msg}{RESET}", flush=True)
def err(msg):                 print(f"{RED}✗ {msg}{RESET}", flush=True)


# ─── STEP 1: Download ────────────────────────────────────────────────────────

def download_video(url: str, out_dir: Path) -> Path:
    log(f"📥 Скачиваю видео: {url}")
    out_path = out_dir / "source.mp4"
    if out_path.exists() and out_path.stat().st_size > 100_000:
        log(f"  Уже скачано: {out_path}")
        return out_path

    cookies_file = SCRIPT_DIR / "cookies.txt"

    def _build_cmd(extra_args=None):
        c = [
            "yt-dlp",
            "-f", "bestvideo[ext=mp4][height<=1080]+bestaudio[ext=m4a]/best[ext=mp4]/best",
            "--merge-output-format", "mp4",
            "--extractor-args", "youtube:player_client=ios,android",
            "--no-playlist",
            "--js-runtimes", "node",
        ]
        if cookies_file.exists():
            c += ["--cookies", str(cookies_file)]
        if extra_args:
            c += extra_args
        c += ["-o", str(out_path), url]
        return c

    # Try with cookies.txt first, then browser cookies, then without
    attempts = [[], ["--cookies-from-browser", "chrome"], ["--cookies-from-browser", "edge"]]
    last_err = ""
    for attempt_args in attempts:
        cmd = _build_cmd(attempt_args)
        result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
        if result.returncode == 0:
            break
        last_err = result.stderr
        if attempt_args:
            warn(f"Попытка с {attempt_args[1]} не удалась, пробую следующий метод...")
    else:
        err(f"yt-dlp failed:\n{last_err[-500:]}")
        raise RuntimeError("Download failed")

    if not out_path.exists():
        # yt-dlp may have renamed
        candidates = list(out_dir.glob("source.*"))
        if candidates:
            out_path = candidates[0]
        else:
            raise RuntimeError("Downloaded file not found")

    log(f"  ✓ Скачано: {out_path} ({out_path.stat().st_size // 1024 // 1024}MB)")

    # Auto-download background music if music dir is empty
    _try_download_music()

    return out_path


def _try_download_music():
    """Скачивает спокойную фоновую музыку если папка music/ пуста."""
    MUSIC_DIR.mkdir(exist_ok=True)
    existing = list(MUSIC_DIR.glob("*.mp3")) + list(MUSIC_DIR.glob("*.m4a"))
    if existing:
        return
    # Lofi / ambient музыка без авторских прав с YouTube
    MUSIC_URLS = [
        "https://www.youtube.com/watch?v=5qap5aO4i9A",  # lofi hip hop
        "https://www.youtube.com/watch?v=jfKfPfyJRdk",  # lofi girl
    ]
    log("🎵 Скачиваю фоновую музыку...")
    music_path = MUSIC_DIR / "background.mp3"
    cookies_file = SCRIPT_DIR / "cookies.txt"
    for music_url in MUSIC_URLS:
        cmd = [
            "yt-dlp", "-x", "--audio-format", "mp3",
            "--no-playlist", "-o", str(music_path),
        ]
        if cookies_file.exists():
            cmd += ["--cookies", str(cookies_file)]
        cmd.append(music_url)
        r = subprocess.run(cmd, capture_output=True, timeout=120)
        if r.returncode == 0 and music_path.exists():
            log(f"  ✓ Музыка скачана: {music_path}")
            return
    warn("Не удалось скачать музыку — клипы будут без фона")


def get_video_info(url: str) -> dict:
    """Get title, duration, channel via yt-dlp."""
    cookies_file = SCRIPT_DIR / "cookies.txt"
    cmd = [
        "yt-dlp", "--dump-json", "--no-download",
        "--extractor-args", "youtube:player_client=ios,android",
        "--js-runtimes", "node",
    ]
    if cookies_file.exists():
        cmd += ["--cookies", str(cookies_file)]
    cmd.append(url)
    result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
    if result.returncode == 0:
        try:
            data = json.loads(result.stdout.strip().split("\n")[0])
            return {
                "title": data.get("title", "Unknown"),
                "duration": data.get("duration", 0),
                "channel": data.get("channel", ""),
                "description": (data.get("description") or "")[:500],
            }
        except Exception:
            pass
    return {"title": "Unknown", "duration": 0, "channel": "", "description": ""}


# ─── STEP 2: Transcribe ──────────────────────────────────────────────────────

def transcribe_whisper(video_path: Path, out_dir: Path, model_name: str = "base", lang: str = None) -> list[dict]:
    """
    Транскрибирует видео через Whisper локально.
    Возвращает список сегментов: [{start, end, text}, ...]
    """
    transcript_path = out_dir / "transcript.json"
    if transcript_path.exists():
        log("  Транскрипция уже есть, пропускаю...")
        with open(transcript_path, encoding="utf-8") as f:
            return json.load(f)

    log(f"🎙 Транскрибирую через Whisper ({model_name})...")
    log("  Первый запуск — скачает модель (~74MB для base)...")

    try:
        import whisper
    except ImportError:
        err("whisper не установлен. Запусти: pip install openai-whisper")
        raise

    model = whisper.load_model(model_name)
    opts = {"verbose": False, "word_timestamps": True}
    if lang:
        opts["language"] = lang

    result = model.transcribe(str(video_path), **opts)

    segments = []
    for seg in result["segments"]:
        segments.append({
            "start": round(seg["start"], 2),
            "end":   round(seg["end"], 2),
            "text":  seg["text"].strip(),
        })

    with open(transcript_path, "w", encoding="utf-8") as f:
        json.dump(segments, f, ensure_ascii=False, indent=2)

    log(f"  ✓ {len(segments)} сегментов транскрибировано")
    return segments


def segments_to_text(segments: list[dict]) -> str:
    """Собирает полный текст с таймкодами для передачи в LLM."""
    lines = []
    for seg in segments:
        ts = f"[{seg['start']:.1f}s-{seg['end']:.1f}s]"
        lines.append(f"{ts} {seg['text']}")
    return "\n".join(lines)


# ─── STEP 3: LLM Analysis ────────────────────────────────────────────────────

ANALYSIS_PROMPT = """Ты эксперт по вирусному контенту для YouTube Shorts и TikTok.

Тебе дана транскрипция видео с таймкодами в формате [start_sec-end_sec].

Найди {n_clips} самых цепляющих момента для нарезки в шортсы (20-60 секунд каждый).

Критерии хорошего момента:
- Начинается с сильного hook: провокационный вопрос, неожиданный факт, эмоциональная фраза
- Содержит один чёткий инсайт, конфликт или эмоциональный пик
- Законченная мысль — не обрывается на полуслове
- Подходит для вертикального формата (без важной визуальной информации по краям)

Информация о видео:
Название: {title}
Канал: {channel}

Транскрипция:
{transcript}

Ответь ТОЛЬКО валидным JSON (без markdown блоков), массивом объектов:
[
  {{
    "start": 45.2,
    "end": 87.5,
    "hook": "Первая фраза клипа (для превью)",
    "reason": "Почему это цепляет",
    "title": "Короткое название клипа (3-5 слов)"
  }},
  ...
]

Важно: start и end должны точно совпадать с таймкодами из транскрипции."""


def analyze_with_llm(segments: list[dict], video_info: dict, n_clips: int, out_dir: Path) -> list[dict]:
    """Использует Claude/LLM для поиска лучших моментов."""
    clips_path = out_dir / "clips_plan.json"
    if clips_path.exists():
        log("  План клипов уже есть, пропускаю LLM...")
        with open(clips_path, encoding="utf-8") as f:
            return json.load(f)

    log(f"🤖 Анализирую транскрипцию через LLM (ищу {n_clips} момента)...")

    transcript_text = segments_to_text(segments)
    # Ограничиваем до ~8000 символов чтобы не превысить контекст
    if len(transcript_text) > 8000:
        transcript_text = transcript_text[:8000] + "\n...[обрезано]"

    prompt = ANALYSIS_PROMPT.format(
        n_clips=n_clips,
        title=video_info.get("title", ""),
        channel=video_info.get("channel", ""),
        transcript=transcript_text,
    )

    from openai import OpenAI
    api_key = os.environ.get("OPENAI_API_KEY", "")
    base_url = os.environ.get("OPENAI_BASE_URL", "https://llm.lambda.coredump.ru/v1")
    model = os.environ.get("STORY_MODEL", "qwen3.5-122b")

    client = OpenAI(api_key=api_key, base_url=base_url)
    response = client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": "You are a viral content expert. Respond ONLY with valid JSON, no thinking, no explanation."},
            {"role": "user", "content": prompt},
        ],
        temperature=0.3,
        max_tokens=16000,
    )

    msg = response.choices[0].message
    content = msg.content
    if not content:
        # Thinking model: try reasoning_content
        content = getattr(msg, "reasoning_content", None) or ""
    raw = content.strip()
    # Strip <think>...</think> blocks
    raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
    if not raw:
        err(f"LLM вернул пустой ответ (finish_reason={response.choices[0].finish_reason})")
        raise RuntimeError("LLM empty response")
    # Strip markdown code blocks if present
    raw = re.sub(r"^```(?:json)?\s*", "", raw, flags=re.MULTILINE)
    raw = re.sub(r"\s*```$", "", raw, flags=re.MULTILINE)

    try:
        clips = json.loads(raw)
    except json.JSONDecodeError:
        # Try to extract JSON array from response
        m = re.search(r'\[.*\]', raw, re.DOTALL)
        if m:
            clips = json.loads(m.group(0))
        else:
            err(f"LLM вернул не JSON:\n{raw[:300]}")
            raise RuntimeError("LLM parsing failed")

    # Validate and clamp
    valid_clips = []
    max_end = segments[-1]["end"] if segments else 999999
    for c in clips:
        start = float(c.get("start", 0))
        end   = float(c.get("end", start + 30))
        if end > max_end:
            end = max_end
        duration = end - start
        if duration < 5:
            warn(f"Пропускаю клип {c.get('title','?')} — слишком короткий ({duration:.1f}s)")
            continue
        if duration > 90:
            end = start + 60
        valid_clips.append({
            "start": round(start, 1),
            "end":   round(end, 1),
            "hook":  c.get("hook", ""),
            "reason": c.get("reason", ""),
            "title": c.get("title", f"clip_{len(valid_clips)+1}"),
        })

    with open(clips_path, "w", encoding="utf-8") as f:
        json.dump(valid_clips, f, ensure_ascii=False, indent=2)

    for i, c in enumerate(valid_clips, 1):
        log(f"  {i}. [{c['start']}s-{c['end']}s] {c['title']}")
        log(f"     Hook: {c['hook'][:60]}", YELLOW)

    return valid_clips


# ─── STEP 4: Build subtitles ─────────────────────────────────────────────────

def build_ass_subtitles(segments: list[dict], clip_start: float, clip_end: float, out_path: Path):
    """
    Создаёт .ass файл субтитров для одного клипа.
    Время пересчитывается относительно начала клипа.
    """
    def sec_to_ass(s: float) -> str:
        s = max(0, s)
        h = int(s // 3600)
        m = int((s % 3600) // 60)
        sec = s % 60
        cs = int((sec - int(sec)) * 100)
        return f"{h}:{m:02d}:{int(sec):02d}.{cs:02d}"

    header = """\
[Script Info]
ScriptType: v4.00+
PlayResX: 720
PlayResY: 1280
WrapStyle: 0

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,52,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,40,40,120,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
    lines = [header]
    for seg in segments:
        # Only include segments that overlap with this clip
        if seg["end"] <= clip_start or seg["start"] >= clip_end:
            continue
        rel_start = max(0, seg["start"] - clip_start)
        rel_end   = min(clip_end - clip_start, seg["end"] - clip_start)
        text = seg["text"].replace("\n", " ").strip()
        if not text:
            continue
        # Word-wrap at ~30 chars per line
        words = text.split()
        lines_text = []
        cur = []
        for w in words:
            cur.append(w)
            if len(" ".join(cur)) > 28:
                lines_text.append(" ".join(cur[:-1]))
                cur = [w]
        if cur:
            lines_text.append(" ".join(cur))
        wrapped = r"\N".join(lines_text)
        lines.append(f"Dialogue: 0,{sec_to_ass(rel_start)},{sec_to_ass(rel_end)},Default,,0,0,0,,{wrapped}")

    out_path.write_text("\n".join(lines), encoding="utf-8")


# ─── STEP 5: Cut + Crop + Subtitles + Music ──────────────────────────────────

def get_video_dimensions(video_path: Path) -> tuple[int, int]:
    cmd = [
        "ffprobe", "-v", "quiet", "-print_format", "json",
        "-show_streams", str(video_path)
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
    try:
        data = json.loads(result.stdout)
        for s in data["streams"]:
            if s.get("codec_type") == "video":
                return int(s["width"]), int(s["height"])
    except Exception:
        pass
    return 1920, 1080


def find_music(music_arg: str | None) -> str | None:
    """Finds background music file."""
    if music_arg and Path(music_arg).exists():
        return music_arg

    # Search in clip-extractor/music/
    for ext in ["mp3", "m4a", "wav", "ogg", "aac"]:
        found = list(MUSIC_DIR.glob(f"*.{ext}"))
        if found:
            return str(found[0])

    return None


def detect_face_region(source: Path, timestamp: float, w: int, h: int) -> tuple | None:
    """Находит лицо в кадре через OpenCV. Возвращает (x, y, crop_w, crop_h) или None."""
    try:
        import cv2
        frame_path = source.parent / "_face_probe.jpg"
        subprocess.run([
            "ffmpeg", "-y", "-ss", str(timestamp), "-i", str(source),
            "-frames:v", "1", "-q:v", "2", str(frame_path)
        ], capture_output=True, timeout=10)
        if not frame_path.exists():
            return None

        img = cv2.imread(str(frame_path))
        frame_path.unlink(missing_ok=True)
        if img is None:
            return None

        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
        faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60))
        if len(faces) == 0:
            return None

        fx, fy, fw, fh = max(faces, key=lambda f: f[2] * f[3])
        face_cx = fx + fw // 2

        target_w = int(h * 9 / 16)
        x = max(0, min(face_cx - target_w // 2, w - target_w))
        return (x, 0, target_w, h)

    except Exception:
        return None


def cut_clip(
    source: Path,
    start: float,
    end: float,
    segments: list[dict],
    out_path: Path,
    music_file: str | None,
    crop_vertical: bool = True,
    zoom_faces: bool = True,
):
    """Вырезает клип, кадрирует 9:16, зумит на лица, сжигает субтитры, накладывает музыку."""
    duration = end - start
    w, h = get_video_dimensions(source)

    # Build subtitle file
    ass_path = out_path.with_suffix(".ass")
    build_ass_subtitles(segments, start, end, ass_path)

    # Crop filter: if horizontal video → crop to 9:16 around face
    crop_filter = ""
    if crop_vertical and w > h:
        target_w = int(h * 9 / 16)
        if target_w > w:
            target_w = w
        x_offset = (w - target_w) // 2  # default center

        if zoom_faces:
            mid = start + duration / 2
            face = detect_face_region(source, mid, w, h)
            if face:
                x_offset = face[0]
                log(f"  👤 Лицо найдено — зумирую (x={x_offset})")
            else:
                log(f"  👤 Лицо не найдено — центральный кроп")

        crop_filter = f"crop={target_w}:{h}:{x_offset}:0,scale=720:1280:flags=lanczos,"
    elif w != 720 or h != 1280:
        crop_filter = "scale=720:1280:flags=lanczos,"

    # Subtitle filter (escape path for ffmpeg on Windows)
    ass_escaped = str(ass_path).replace("\\", "/").replace(":", "\\:")
    vf = f"{crop_filter}ass='{ass_escaped}'"

    # Base ffmpeg command
    cmd = [
        "ffmpeg", "-y",
        "-ss", str(start),
        "-i", str(source),
        "-t", str(duration),
    ]

    if music_file:
        cmd += ["-stream_loop", "-1", "-i", music_file]
        # Mix original audio with background music at low volume (-20dB)
        cmd += [
            "-filter_complex",
            f"[0:v]{vf}[v];"
            f"[1:a]volume=0.12[music];"
            f"[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[aout]",
            "-map", "[v]",
            "-map", "[aout]",
            "-c:v", "libx264", "-crf", "23", "-preset", "fast",
            "-c:a", "aac", "-b:a", "128k",
            "-movflags", "+faststart",
            str(out_path),
        ]
    else:
        # Keep original audio, just burn subtitles and crop
        cmd += [
            "-vf", vf,
            "-map", "0:v",
            "-map", "0:a?",  # keep original audio if present
            "-c:v", "libx264", "-crf", "23", "-preset", "fast",
            "-c:a", "aac", "-b:a", "128k",
            "-movflags", "+faststart",
            str(out_path),
        ]

    result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
    if result.returncode != 0:
        err(f"ffmpeg failed:\n{result.stderr[-800:]}")
        raise RuntimeError(f"ffmpeg failed for {out_path.name}")

    # Cleanup temp ass
    if ass_path.exists():
        ass_path.unlink()


# ─── STEP 6: Download free music ─────────────────────────────────────────────

def ensure_music(music_dir: Path) -> str | None:
    """Downloads a free background track if music dir is empty."""
    music_dir.mkdir(exist_ok=True)
    existing = list(music_dir.glob("*.mp3")) + list(music_dir.glob("*.m4a"))
    if existing:
        return str(existing[0])

    log("🎵 Скачиваю бесплатный фоновый трек с YouTube Audio Library...")
    # Lofi hip-hop, no copyright — public domain / CC0 track via yt-dlp
    # Using a known royalty-free track from YouTube
    music_urls = [
        # Lofi hip-hop beats from YouTube Audio Library (CC0)
        "https://www.youtube.com/watch?v=jfKfPfyJRdk",  # lofi girl stream
    ]
    out_path = music_dir / "background.mp3"
    for music_url in music_urls:
        cmd = [
            "yt-dlp",
            "-f", "bestaudio[ext=m4a]/bestaudio",
            "--extract-audio", "--audio-format", "mp3", "--audio-quality", "5",
            "--extractor-args", "youtube:player_client=ios,android",
            "-o", str(music_dir / "background.%(ext)s"),
            music_url,
        ]
        result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
        candidates = list(music_dir.glob("background.*"))
        if candidates:
            log(f"  ✓ Музыка скачана: {candidates[0]}")
            return str(candidates[0])
    warn("Не удалось скачать музыку, клипы будут без фоновой музыки")
    return None


# ─── MAIN ─────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="Нарезка YouTube видео на вирусные шортсы")
    parser.add_argument("url", help="YouTube URL")
    parser.add_argument("--clips", type=int, default=3, help="Количество клипов (default: 3)")
    parser.add_argument("--lang", default=None, help="Язык транскрипции: ru, en, auto (default: auto)")
    parser.add_argument("--whisper-model", default="base",
                        choices=["tiny", "base", "small", "medium"],
                        help="Размер модели Whisper (default: base)")
    parser.add_argument("--music", default=None, help="Путь к MP3 для фоновой музыки")
    parser.add_argument("--no-music", action="store_true", help="Без фоновой музыки")
    parser.add_argument("--no-crop", action="store_true", help="Не кадрировать в 9:16")
    parser.add_argument("--out-dir", default=None, help="Папка для результата")
    args = parser.parse_args()

    # Setup output dir
    if args.out_dir:
        work_dir = Path(args.out_dir)
    else:
        OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
        # Use video ID as subdir
        vid_id = re.search(r"(?:v=|shorts/|youtu\.be/)([A-Za-z0-9_-]{11})", args.url)
        folder_name = vid_id.group(1) if vid_id else "video"
        work_dir = OUTPUT_DIR / folder_name
    work_dir.mkdir(parents=True, exist_ok=True)

    log(f"📂 Рабочая папка: {work_dir}")

    # 1. Download (or use local file directly)
    local = Path(args.url)
    if local.exists() and local.is_file():
        log(f"📁 Локальный файл: {local}")
        video_path = local
        video_info = {"title": local.stem, "channel": "local"}
    else:
        video_info = get_video_info(args.url)
        log(f"📺 Видео: {video_info['title']} [{video_info.get('channel','')}]")
        video_path = download_video(args.url, work_dir)

    # 2. Transcribe
    lang = args.lang if args.lang != "auto" else None
    segments = transcribe_whisper(video_path, work_dir, model_name=args.whisper_model, lang=lang)

    if not segments:
        err("Транскрипция пустая — возможно видео без речи?")
        sys.exit(1)

    total_dur = segments[-1]["end"]
    log(f"  Длительность: {total_dur:.0f}s, сегментов: {len(segments)}")

    # 3. LLM analysis
    clips_plan = analyze_with_llm(segments, video_info, args.clips, work_dir)
    if not clips_plan:
        err("LLM не нашёл подходящих моментов")
        sys.exit(1)

    # 4. Music
    if args.no_music:
        music_file = None
    elif args.music:
        music_file = args.music
    else:
        music_file = find_music(None) or ensure_music(MUSIC_DIR)

    if music_file:
        log(f"🎵 Музыка: {Path(music_file).name}")
    else:
        warn("Без музыки")

    # 5. Cut clips
    log(f"\n✂️  Нарезаю {len(clips_plan)} клипов...")
    results = []
    for i, clip in enumerate(clips_plan, 1):
        safe_title = re.sub(r'[^\w\s-]', '', clip['title']).strip().replace(' ', '_')[:30]
        out_path = work_dir / f"clip_{i:02d}_{safe_title}.mp4"

        log(f"\n[{i}/{len(clips_plan)}] {clip['title']} ({clip['start']}s → {clip['end']}s)")
        log(f"  Hook: {clip['hook'][:70]}", YELLOW)
        log(f"  Причина: {clip['reason'][:70]}", YELLOW)

        try:
            cut_clip(
                source=video_path,
                start=clip["start"],
                end=clip["end"],
                segments=segments,
                out_path=out_path,
                music_file=music_file,
                crop_vertical=not args.no_crop,
            )
            size_mb = out_path.stat().st_size / 1024 / 1024
            log(f"  ✓ Сохранён: {out_path.name} ({size_mb:.1f}MB)")
            results.append({"clip": i, "file": str(out_path), "meta": clip})
        except Exception as ex:
            err(f"  Ошибка: {ex}")

    # 6. Save manifest
    manifest = {
        "url": args.url,
        "video": video_info,
        "clips": results,
    }
    manifest_path = work_dir / "manifest.json"
    with open(manifest_path, "w", encoding="utf-8") as f:
        json.dump(manifest, f, ensure_ascii=False, indent=2)

    log(f"\n{'='*50}")
    log(f"✅ Готово! {len(results)} клипов сохранено в {work_dir}")
    for r in results:
        log(f"  {r['clip']}. {Path(r['file']).name}")

    # Print paths to stdout for piping
    for r in results:
        print(r["file"])

    return 0


if __name__ == "__main__":
    sys.exit(main())
