Skill: huuuwnnn-droid/video-insight (version 1.0.0)

--- name: video-insight description: Cross-platform video transcript extraction and optional AI summarization for YouTube and Bilibili. GPU auto-detect. Transcript-first with opt-in LLM summary. --- # video-insight Cross-platform video transcript extraction and optional AI summarization for YouTube and Bilibili. ## Description Extract transcripts, metadata, and optional keyframes from YouTube and Bilibili videos. Outputs structured JSON to stdout. By default, **no LLM summarization** is performed — the agent receives the full transcript and does its own summarization with full context window. Supports: macOS, Linux, WSL, Windows VM. ## Usage ```bash # Single video — transcript only (default, recommended) video-insight --url "https://www.youtube.com/watch?v=VIDEO_ID" # Bilibili video video-insight --url "https://www.bilibili.com/video/BV1xxxxx" # With LLM summary (opt-in) video-insight --url "https://..." --summarize # Channel scan (recent videos) video-insight --channel "UC_x5XG1OV2P6uZZ5FSM9Ttw" --hours 24 # Quiet mode (no stderr progress) video-insight --url "https://..." --quiet # Force refresh (ignore cache) video-insight --url "https://..." --no-cache # Extract keyframes too video-insight --url "https://..." --frames ``` ## Triggers Summarize video, extract transcript, YouTube summary, Bilibili transcript, video transcript, 视频摘要, 视频总结, B站视频, YouTube视频 ## Output Schema ```json { "ok": true, "data": { "video_id": "dQw4w9WgXcQ", "platform": "youtube", "title": "Video Title", "channel": "Channel Name", "duration_seconds": 212, "transcript": "Full transcript text without truncation...", "transcript_with_timestamps": "[0.0-3.2] First segment\n[3.2-6.5] Second...", "frames": [{"file": "/tmp/.../frame_001.jpg", "time_sec": 30}], "cached": false }, "error": null } ``` ## Cache Transcripts are permanently cached at `~/.cache/video-insight/{platform}_{video_id}.json`. The `.json` format stores metadata + transcript together for richer cache hits (title, channel, duration, timestamps). Use `--no-cache` to force re-fetch. ## Agent Integration Guide 1. **Default workflow**: Call `video-insight --url <URL>`, receive JSON with full transcript. Use your own LLM context to summarize — you have 128K+ tokens, no need for the script to truncate. 2. **For keyframes**: Add `--frames` flag. Only needed when the user explicitly asks for a visual/image review. 3. **Long videos (2h+)**: The transcript may be very large. Use map-reduce or chunked summarization. 4. **Bilibili videos** require `ffmpeg` and `faster-whisper` (installed via `setup.sh`). YouTube videos typically have captions and are much faster. 5. **Cron/headless**: Use `--summarize --quiet` for automated pipelines. ## Setup ```bash cd ~/.openclaw/skills/video-insight && bash setup.sh ``` ## Dependencies **Required**: `yt-dlp`, `youtube-transcript-api`, `innertube`, `ffmpeg` (system) **Optional**: `faster-whisper` (for Bilibili/captionless videos), `requests` (for --summarize)

path: config/settings.json
content: {
  "default_mode": "text-only",
  "whisper_model": "small",
  "whisper_device": "auto",
  "whisper_compute_type": "auto",
  "whisper_language": "auto",
  "frame_interval": 30,
  "max_frames": 15,
  "cache_dir": "~/.cache/video-insight",
  "disk_space_threshold_gb": 5,
  "yt_dlp_retries": 3,
  "yt_dlp_fragment_retries": 3,
  "yt_dlp_timeout": 300,
  "ffmpeg_timeout": 120,
  "transcript_min_length": 50
}


path: scripts/bilibili.py
content: #!/usr/bin/env python3
"""video-insight Bilibili module: download + whisper transcription + optional keyframes."""

import os
import sys
import glob
import json
import subprocess
from typing import Dict, Optional, List

from utils import (
    extract_bilibili_id, progress, load_settings, get_setting,
    TempManager, check_disk_space, ok_result, err_result
)

SETTINGS = load_settings()


def _get_bilibili_title(url: str) -> str:
    """Get Bilibili video title via yt-dlp --print title."""
    retries = int(get_setting("yt_dlp_retries", 3, settings=SETTINGS))
    try:
        result = subprocess.run(
            ["yt-dlp", "--print", "title", "--retries", str(retries), "--no-warnings", url],
            capture_output=True, text=True, timeout=30,
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip()
    except Exception:
        pass
    return "B站视频"


def _get_bilibili_duration(url: str) -> int:
    """Get Bilibili video duration via yt-dlp."""
    retries = int(get_setting("yt_dlp_retries", 3, settings=SETTINGS))
    try:
        result = subprocess.run(
            ["yt-dlp", "--print", "duration", "--retries", str(retries), "--no-warnings", url],
            capture_output=True, text=True, timeout=30,
        )
        if result.returncode == 0 and result.stdout.strip():
            return int(float(result.stdout.strip()))
    except Exception:
        pass
    return 0


def _get_bilibili_channel(url: str) -> str:
    """Get Bilibili uploader name via yt-dlp."""
    retries = int(get_setting("yt_dlp_retries", 3, settings=SETTINGS))
    try:
        result = subprocess.run(
            ["yt-dlp", "--print", "uploader", "--retries", str(retries), "--no-warnings", url],
            capture_output=True, text=True, timeout=30,
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip()
    except Exception:
        pass
    return "Bilibili"


def select_key_frames(frame_files: list, target_count: int = 10, oversample_ratio: float = 1.5) -> list:
    """Select key frames from extracted frames using uniform sampling."""
    total = len(frame_files)
    actual_count = min(round(target_count * oversample_ratio), total)

    if total <= actual_count + 3:
        return frame_files

    indices = [0]
    step = (total - 1) / (actual_count - 1)
    for i in range(1, actual_count - 1):
        indices.append(round(i * step))
    indices.append(total - 1)
    indices = sorted(set(indices))
    return [frame_files[i] for i in indices]


def process_bilibili(
    url: str,
    cache=None,
    no_cache: bool = False,
    extract_frames: bool = False,
    whisper_model: str = None,
    frame_interval: int = None,
    max_frames: int = None,
    temp_manager: TempManager = None,
) -> dict:
    """Process a Bilibili video. Returns unified result dict."""
    video_id = extract_bilibili_id(url)
    progress(f"🎬 Bilibili: {video_id}")

    # Check cache
    if cache and not no_cache:
        cached = cache.get("bilibili", video_id)
        if cached:
            progress("  ✅ Cache hit")
            return cached

    # Load settings
    whisper_model = whisper_model or get_setting("whisper_model", "small", "WHISPER_MODEL", SETTINGS)
    frame_interval = frame_interval or int(get_setting("frame_interval", 30, "FRAME_INTERVAL", SETTINGS))
    max_frames = max_frames or int(get_setting("max_frames", 15, "MAX_FRAMES", SETTINGS))
    retries = str(get_setting("yt_dlp_retries", 3, settings=SETTINGS))
    frag_retries = str(get_setting("yt_dlp_fragment_retries", 3, settings=SETTINGS))
    yt_dlp_timeout = int(get_setting("yt_dlp_timeout", 300, settings=SETTINGS))
    ffmpeg_timeout = int(get_setting("ffmpeg_timeout", 120, settings=SETTINGS))

    # Disk space check
    threshold = float(get_setting("disk_space_threshold_gb", 5.0, settings=SETTINGS))
    space_ok, free_gb = check_disk_space("/tmp", threshold)
    if not space_ok:
        return err_result(f"Disk space too low: {free_gb:.1f} GB free, need {threshold} GB", "DISK_SPACE")

    # Set up temp directory
    own_temp = False
    if temp_manager is None:
        temp_manager = TempManager()
        own_temp = True

    video_path = temp_manager.get_path(f"bili_{video_id}.mp4")
    audio_path = temp_manager.get_path(f"bili_{video_id}_audio.mp3")
    frames_dir = temp_manager.get_path(f"bili_{video_id}_frames")

    try:
        # Step 0: Get metadata
        progress("  📋 Fetching metadata...")
        title = _get_bilibili_title(url)
        channel = _get_bilibili_channel(url)
        duration = _get_bilibili_duration(url)
        progress(f"  📋 Title: {title}")

        # Step 1: Download video
        progress("  📥 Downloading video...")
        base_cmd = [
            "yt-dlp",
            "--no-check-certificates",
            "--retries", retries,
            "--fragment-retries", frag_retries,
            "-f", "bestvideo[height<=720]+bestaudio/best[height<=720]",
            "--merge-output-format", "mp4",
            "-o", video_path,
            url,
        ]
        try:
            subprocess.run(base_cmd, check=True, timeout=yt_dlp_timeout, capture_output=True)
        except subprocess.CalledProcessError:
            progress("  ⚠️  Retrying with browser cookies...")
            cookie_cmd = [
                "yt-dlp",
                "--cookies-from-browser", "chrome",
                "--no-check-certificates",
                "--retries", retries,
                "--fragment-retries", frag_retries,
                "-f", "bestvideo[height<=720]+bestaudio/best[height<=720]",
                "--merge-output-format", "mp4",
                "-o", video_path,
                url,
            ]
            subprocess.run(cookie_cmd, check=True, timeout=yt_dlp_timeout, capture_output=True)

        # Step 2: Extract audio
        progress("  🎵 Extracting audio...")
        subprocess.run(
            ["ffmpeg", "-y", "-i", video_path, "-vn", "-acodec", "libmp3lame", "-q:a", "2", audio_path],
            check=True, timeout=ffmpeg_timeout, capture_output=True,
        )

        # Step 3: Whisper transcription (language=auto)
        progress(f"  🗣️  Transcribing (whisper {whisper_model})...")
        from faster_whisper import WhisperModel

        whisper_device = os.environ.get("WHISPER_DEVICE", get_setting("whisper_device", "auto", settings=SETTINGS))
        whisper_compute = get_setting("whisper_compute_type", "auto", settings=SETTINGS)

        if whisper_device == "auto":
            try:
                import ctranslate2 as _ct2
                cuda_types = _ct2.get_supported_compute_types("cuda")
                whisper_device = "cuda"
                whisper_compute = "float16" if "float16" in cuda_types else "int8"
            except Exception:
                whisper_device = "cpu"
                whisper_compute = "int8"

        progress(f"  🖥️  Whisper device: {whisper_device} ({whisper_compute})")
        model = WhisperModel(whisper_model, device=whisper_device, compute_type=whisper_compute)

        # language=None means auto-detect (fixes C4)
        whisper_lang = get_setting("whisper_language", "auto", settings=SETTINGS)
        lang_param = None if whisper_lang == "auto" else whisper_lang

        segments_iter, info = model.transcribe(audio_path, language=lang_param)
        segments_list = list(segments_iter)

        progress(f"  🗣️  Detected language: {info.language} (prob={info.language_probability:.2f})")

        transcript_lines = [f"[{s.start:.1f}-{s.end:.1f}] {s.text}" for s in segments_list]
        transcript_with_timestamps = "\n".join(transcript_lines)
        transcript_plain = " ".join(s.text for s in segments_list)

        progress(f"  ✅ Transcript: {len(transcript_plain)} chars")

        # Remove video immediately after audio extraction (save disk)
        temp_manager.remove_early(video_path)

        # Step 4: Extract keyframes (optional)
        frames = []
        if extract_frames:
            progress(f"  🖼️  Extracting keyframes (every {frame_interval}s)...")
            os.makedirs(frames_dir, exist_ok=True)
            subprocess.run(
                [
                    "ffmpeg", "-y", "-i", video_path if os.path.exists(video_path) else audio_path,
                    "-vf", f"fps=1/{frame_interval}", "-q:v", "2",
                    os.path.join(frames_dir, "frame_%03d.jpg"),
                ],
                check=True, timeout=ffmpeg_timeout, capture_output=True,
            )
            frame_files = sorted(glob.glob(os.path.join(frames_dir, "frame_*.jpg")))
            selected = select_key_frames(frame_files, max_frames)
            frame_offset = int(os.environ.get("FRAME_TIME_OFFSET", "5"))
            for fp in selected:
                num = int(os.path.basename(fp).split("_")[1].split(".")[0])
                frames.append({
                    "file": fp,
                    "time_sec": (num - 1) * frame_interval + frame_offset,
                })
            progress(f"  ✅ Keyframes: {len(frames)}")

        result = {
            "video_id": video_id,
            "platform": "bilibili",
            "title": title,
            "channel": channel,
            "duration_seconds": duration,
            "transcript": transcript_plain,
            "transcript_with_timestamps": transcript_with_timestamps,
            "frames": frames,
            "cached": False,
        }

        # Cache transcript
        if cache:
            cache.put("bilibili", video_id, result)
            progress("  💾 Cached")

        progress(f"  ✅ Done: {len(transcript_plain)} chars")
        return result

    except FileNotFoundError as e:
        cmd = str(e).split("'")[-2] if "'" in str(e) else "unknown"
        return err_result(f"Missing dependency: {cmd}. Run setup.sh first.", "MISSING_DEP")
    except subprocess.CalledProcessError as e:
        return err_result(f"Command failed: {e.cmd[0] if e.cmd else 'unknown'} (exit {e.returncode})", "CMD_FAILED")
    except subprocess.TimeoutExpired:
        return err_result("Processing timed out. Video may be too large or network too slow.", "TIMEOUT")
    except ImportError as e:
        return err_result(f"Missing Python package: {e.name}. Run setup.sh to install.", "MISSING_PACKAGE")
    except Exception as e:
        return err_result(f"Unexpected error: {type(e).__name__}: {e}", "UNKNOWN")
    finally:
        if own_temp:
            temp_manager.cleanup()


path: scripts/cli.py
content: #!/usr/bin/env python3
"""video-insight CLI entry point.

Usage:
  cli.py --url "https://youtube.com/watch?v=VIDEO_ID"
  cli.py --url "https://www.bilibili.com/video/BV1xxxxx"
  cli.py --channel "UC_x5XG1OV2P6uZZ5FSM9Ttw" --hours 24
  cli.py --config channels.json --daily --output /tmp/result.json

Output: JSON to stdout. Progress to stderr.
"""

import os
import sys
import json
import argparse
from datetime import datetime, timezone
from pathlib import Path

# Ensure scripts/ is on the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from utils import (
    detect_platform, extract_youtube_id, extract_bilibili_id,
    load_settings, get_setting, set_quiet, progress,
    ok_result, err_result, Cache, TempManager,
)


def build_parser() -> argparse.ArgumentParser:
    settings = load_settings()

    parser = argparse.ArgumentParser(
        description="video-insight — Cross-platform video transcript extraction",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    # Input sources
    parser.add_argument("--url", help="Video URL (YouTube or Bilibili)")
    parser.add_argument("--channel", help="YouTube channel ID or handle")
    parser.add_argument("--config", help="Channel config JSON for batch mode")
    parser.add_argument("--daily", action="store_true", help="Daily batch mode (with --config)")

    # Output control
    parser.add_argument("--output", "-o", help="Write JSON to file instead of stdout")
    parser.add_argument("--quiet", "-q", action="store_true", help="Suppress stderr progress")
    parser.add_argument("--pretty", action="store_true", default=True, help="Pretty-print JSON (default)")
    parser.add_argument("--compact", action="store_true", help="Compact JSON output")

    # Cache
    parser.add_argument("--no-cache", action="store_true", help="Ignore cache, force re-fetch")
    parser.add_argument("--cache-dir", default=None, help="Custom cache directory")

    # Processing options
    parser.add_argument("--frames", action="store_true", help="Extract keyframes (off by default)")
    parser.add_argument("--summarize", action="store_true", help="Generate LLM summary (off by default)")
    parser.add_argument("--keep-temp", action="store_true", help="Keep temporary files for debugging")

    # Whisper / Bilibili options
    parser.add_argument(
        "--whisper-model",
        default=os.environ.get("WHISPER_MODEL", settings.get("whisper_model", "small")),
        help="Whisper model size (default: small)",
    )
    parser.add_argument(
        "--frame-interval", type=int,
        default=int(os.environ.get("FRAME_INTERVAL", settings.get("frame_interval", 30))),
        help="Keyframe interval in seconds (default: 30)",
    )
    parser.add_argument(
        "--max-frames", type=int,
        default=int(os.environ.get("MAX_FRAMES", settings.get("max_frames", 15))),
        help="Max keyframes to select (default: 15)",
    )

    # Channel scan options
    parser.add_argument("--hours", type=int, default=24, help="Hours to look back (channel mode)")
    parser.add_argument("--max-videos", type=int, default=5, help="Max videos per channel")

    return parser


def output_json(data: dict, args):
    """Output JSON to stdout or file."""
    indent = None if args.compact else 2
    json_str = json.dumps(data, indent=indent, ensure_ascii=False)

    if args.output:
        out_path = Path(args.output)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        with open(out_path, "w", encoding="utf-8") as f:
            f.write(json_str)
        progress(f"📄 Output written to: {out_path}")
    else:
        print(json_str)


def handle_single_url(args, cache, temp_manager):
    """Process a single video URL."""
    url = args.url
    platform = detect_platform(url)

    if platform == "youtube":
        from youtube import process_youtube
        video_id = extract_youtube_id(url)
        if not video_id:
            return err_result(f"Cannot extract YouTube video ID from: {url}", "PARSE_ERROR")

        result_data = process_youtube(
            video_id,
            cache=cache,
            no_cache=args.no_cache,
            extract_frames=args.frames,
        )

    elif platform == "bilibili":
        from bilibili import process_bilibili
        result_data = process_bilibili(
            url,
            cache=cache,
            no_cache=args.no_cache,
            extract_frames=args.frames,
            whisper_model=args.whisper_model,
            frame_interval=args.frame_interval,
            max_frames=args.max_frames,
            temp_manager=temp_manager,
        )

    else:
        return err_result(f"Unsupported platform URL: {url}", "UNSUPPORTED_PLATFORM")

    # If processor returned a unified error struct, pass through
    if isinstance(result_data, dict) and "ok" in result_data and not result_data["ok"]:
        return result_data

    # Opt-in LLM summary
    if args.summarize and result_data.get("transcript"):
        from llm import generate_summary
        progress("  🤖 Generating LLM summary...")
        summary = generate_summary(
            title=result_data.get("title", ""),
            channel=result_data.get("channel", ""),
            duration_seconds=result_data.get("duration_seconds", 0),
            transcript=result_data["transcript"],
        )
        if summary:
            result_data["summary"] = summary
            progress(f"  ✅ Summary: {len(summary)} chars")
        else:
            result_data["summary"] = None
            progress("  ⚠️  Summary generation failed")

    return ok_result(result_data)


def handle_channel(args, cache, temp_manager):
    """Process a YouTube channel scan."""
    from youtube import get_channel_videos, process_youtube

    progress(f"📺 Scanning channel: {args.channel}")
    videos = get_channel_videos(args.channel, args.hours, args.max_videos)
    progress(f"📺 Found {len(videos)} videos")

    items = []
    for v in videos:
        result_data = process_youtube(
            v["id"],
            cache=cache,
            no_cache=args.no_cache,
            extract_frames=args.frames,
        )
        items.append(result_data)

    return ok_result({
        "channel": args.channel,
        "videos_found": len(videos),
        "items": items,
        "generated_at": datetime.now(timezone.utc).isoformat(),
    })


def handle_daily_batch(args, cache, temp_manager):
    """Process daily batch from config file."""
    from youtube import get_channel_videos, process_youtube

    with open(args.config, "r", encoding="utf-8") as f:
        config = json.load(f)

    channels = config.get("channels", [])
    hours = config.get("hours_lookback", args.hours)
    max_videos = config.get("max_videos_per_channel", args.max_videos)

    progress(f"📺 Batch: {len(channels)} channels")

    all_items = []
    for ch in channels:
        channel_id = ch.get("id") or ch.get("url")
        channel_name = ch.get("name", "Unknown")
        progress(f"\n🔍 Channel: {channel_name}")

        videos = get_channel_videos(channel_id, hours, max_videos)
        progress(f"  Found {len(videos)} videos")

        for v in videos:
            result_data = process_youtube(
                v["id"],
                cache=cache,
                no_cache=args.no_cache,
                extract_frames=args.frames,
            )
            all_items.append(result_data)

    return ok_result({
        "channels_scanned": len(channels),
        "items": all_items,
        "generated_at": datetime.now(timezone.utc).isoformat(),
    })


def main():
    parser = build_parser()
    args = parser.parse_args()

    # Quiet mode
    if args.quiet:
        set_quiet(True)

    # Print effective config to stderr
    settings = load_settings()
    progress(f"[video-insight] config: mode=transcript-only summarize={args.summarize} "
             f"frames={args.frames} cache={not args.no_cache}")

    # Clean up orphaned temp dirs from crashed runs
    TempManager.cleanup_orphans()

    # Initialize cache
    cache_dir = args.cache_dir or os.path.expanduser(
        settings.get("cache_dir", "~/.cache/video-insight")
    )
    cache = Cache(cache_dir) if not args.no_cache else None

    # Initialize temp manager
    temp_manager = TempManager() if not args.keep_temp else None

    try:
        if args.url:
            result = handle_single_url(args, cache, temp_manager)
        elif args.channel:
            result = handle_channel(args, cache, temp_manager)
        elif args.daily and args.config:
            result = handle_daily_batch(args, cache, temp_manager)
        else:
            parser.print_help(sys.stderr)
            sys.exit(1)

        output_json(result, args)

    finally:
        # Cleanup temp files (unless --keep-temp)
        if temp_manager:
            temp_manager.cleanup()


if __name__ == "__main__":
    main()


path: scripts/llm.py
content: #!/usr/bin/env python3
"""video-insight LLM module: opt-in summarization via OpenClaw Gateway."""

import os
import json
from typing import Optional

from utils import progress, load_settings, get_setting

SETTINGS = load_settings()

# ──────────────────────────────────────────────
# Summary prompt — loadable from external file
# ──────────────────────────────────────────────

DEFAULT_PROMPT = """You are a professional content analyst. Generate a structured, in-depth summary for this video transcript.

Video Title: {title}
Channel: {channel}
Duration: {duration}

Transcript:
{transcript}

Output format:

### 🎯 Core Topic
- What problem/topic does this video address?

### 💡 Key Points (2-3 sentences each)
1. **Point 1**: ...
2. **Point 2**: ...
3. **Point 3**: ...

### 🛠️ Actionable Takeaways
1. ...
2. ...

### ⚠️ Caveats & Limitations
- ...

### 📊 One-line Summary
- ..."""


def _load_prompt_template() -> str:
    """Load prompt from external file if exists, else use default."""
    from pathlib import Path
    skill_root = Path(__file__).resolve().parent.parent
    prompt_file = skill_root / "config" / "prompt.md"
    if prompt_file.exists():
        try:
            return prompt_file.read_text(encoding="utf-8")
        except Exception:
            pass
    return DEFAULT_PROMPT


def _call_llm(api_url: str, api_key: str, model: str, prompt: str, timeout: int = 120) -> Optional[str]:
    """Call an OpenAI-compatible chat API."""
    try:
        import requests
        headers = {"Content-Type": "application/json"}
        if [REDACTED_SECRET]"Authorization"] = f"Bearer {api_key}"

        response = requests.post(
            api_url,
            headers=headers,
            json={
                "model": model,
                "messages": [{"role": "user", "content": prompt}],
                "max_tokens": 4000,
            },
            timeout=timeout,
        )
        if response.status_code == 200:
            return response.json()["choices"][0]["message"]["content"]
        progress(f"  ⚠️  LLM API error: {response.status_code} {response.text[:200]}")
    except Exception as e:
        progress(f"  ⚠️  LLM call error: {e}")
    return None


def generate_summary(
    title: str,
    channel: str,
    duration_seconds: int,
    transcript: str,
) -> Optional[str]:
    """Generate summary using LLM (single backend: env-configured or OpenClaw Gateway).

    This is opt-in only (--summarize flag). By default the agent does its own summarization.

    Backend resolution:
      1. LLM_API_URL + LLM_API_KEY env vars
      2. OPENCLAW_GATEWAY_TOKEN → localhost gateway
      3. Give up (no multi-fallback chain)
    """
    template = _load_prompt_template()

    # Format duration
    mins = duration_seconds // 60
    secs = duration_seconds % 60
    duration_str = f"{mins}:{secs:02d}" if duration_seconds > 0 else "unknown"

    prompt = template.format(
        title=title,
        channel=channel,
        duration=duration_str,
        transcript=transcript,  # Full transcript, no truncation
    )

    # Backend 1: explicit env
    env_url = os.environ.get("LLM_API_URL")
    env_key = os.environ.get("LLM_API_KEY")
    env_model = os.environ.get("LLM_MODEL", "gpt-4o-mini")

    if env_url and env_key:
        progress(f"  🔑 LLM: {env_url}")
        result = _call_llm(env_url, env_key, env_model, prompt)
        if result:
            return result

    # Backend 2: OpenClaw Gateway
    oc_token = os.environ.get("OPENCLAW_GATEWAY_TOKEN")
    if oc_token:
        gw_url = env_url or "http://localhost:18789/v1/chat/completions"
        progress(f"  🔑 LLM: OpenClaw Gateway → {gw_url}")
        result = _call_llm(gw_url, oc_token, env_model, prompt)
        if result:
            return result

    progress("  ⚠️  No LLM backend available. Skipping summary.")
    return None


path: scripts/utils.py
content: #!/usr/bin/env python3
"""video-insight utilities: platform detection, URL parsing, cache, temp cleanup, disk check, progress."""

import os
import re
import sys
import json
import atexit
import shutil
import signal
import hashlib
import tempfile
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional, Tuple
from urllib.parse import urlparse, parse_qs


# ──────────────────────────────────────────────
# Unified error/result structure
# ──────────────────────────────────────────────

def ok_result(data: dict) -> dict:
    """Wrap successful output."""
    return {"ok": True, "data": data, "error": None}


def err_result(error: str, code: str = "UNKNOWN") -> dict:
    """Wrap error output."""
    return {"ok": False, "data": None, "error": {"code": code, "message": error}}


# ──────────────────────────────────────────────
# Progress (stderr only)
# ──────────────────────────────────────────────

_quiet = False


def set_quiet(quiet: bool):
    global _quiet
    _quiet = quiet


def progress(msg: str):
    """Print progress to stderr (suppressed in --quiet mode)."""
    if not _quiet:
        print(msg, file=sys.stderr, flush=True)


# ──────────────────────────────────────────────
# Platform detection & URL parsing
# ──────────────────────────────────────────────

def detect_platform(url: str) -> str:
    """Detect video platform from URL."""
    if "bilibili.com" in url or "b23.tv" in url:
        return "bilibili"
    elif "youtube.com" in url or "youtu.be" in url:
        return "youtube"
    return "unknown"


def extract_youtube_id(url: str) -> Optional[str]:
    """Extract YouTube video ID from various URL formats.
    Supports: watch, shorts, embed, youtu.be, live, v/ paths.
    """
    parsed = urlparse(url)

    # youtu.be/VIDEO_ID
    if parsed.hostname and "youtu.be" in parsed.hostname:
        vid = parsed.path.lstrip("/").split("/")[0].split("?")[0]
        return vid if vid else None

    # youtube.com/watch?v=VIDEO_ID
    if parsed.hostname and "youtube.com" in parsed.hostname:
        qs = parse_qs(parsed.query)
        if "v" in qs:
            return qs["v"][0]

        # /shorts/VIDEO_ID, /embed/VIDEO_ID, /live/VIDEO_ID, /v/VIDEO_ID
        path_parts = parsed.path.strip("/").split("/")
        if len(path_parts) >= 2 and path_parts[0] in ("shorts", "embed", "live", "v"):
            return path_parts[1]

    return None


def extract_bilibili_id(url: str) -> str:
    """Extract BV number from Bilibili URL (follows b23.tv short links).
    Uses urllib.parse for proper URL parsing.
    """
    if "b23.tv" in url:
        try:
            import requests
            r = requests.head(url, allow_redirects=True, timeout=10)
            url = r.url
        except Exception:
            pass

    parsed = urlparse(url)
    # Extract BV id from path segments, e.g. /video/BV1xx411c7mD or /video/BV1xx411c7mD/
    path_parts = [p for p in parsed.path.split("/") if p]
    for part in path_parts:
        if part.startswith("BV") and len(part) >= 10:
            return part
    return "unknown"


# ──────────────────────────────────────────────
# Config
# ──────────────────────────────────────────────

def load_settings() -> dict:
    """Load settings from config/settings.json relative to skill root."""
    skill_root = Path(__file__).resolve().parent.parent
    config_path = skill_root / "config" / "settings.json"
    if config_path.exists():
        try:
            with open(config_path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            pass
    return {}


def get_setting(key: str, default=None, env_key: str = None, settings: dict = None):
    """Get a setting with priority: env > settings.json > default."""
    if env_key:
        val = os.environ.get(env_key)
        if val is not None:
            return val
    if settings and key in settings:
        return settings[key]
    return default


# ──────────────────────────────────────────────
# Disk space check
# ──────────────────────────────────────────────

def check_disk_space(path: str = "/tmp", threshold_gb: float = 5.0) -> Tuple[bool, float]:
    """Check if disk has enough free space. Returns (ok, free_gb)."""
    try:
        usage = shutil.disk_usage(path)
        free_gb = usage.free / (1024 ** 3)
        return free_gb >= threshold_gb, free_gb
    except Exception:
        return True, -1  # Can't check, proceed anyway


# ──────────────────────────────────────────────
# Temporary file manager
# ──────────────────────────────────────────────

class TempManager:
    """Process-level temporary file management with auto-cleanup."""

    def __init__(self, base_dir: str = "/tmp", prefix: str = "vi-"):
        self.base_dir = base_dir
        self.work_dir = tempfile.mkdtemp(prefix=prefix, dir=base_dir)
        self._registered = []
        self._cleaned = False

        atexit.register(self.cleanup)
        # Handle SIGTERM gracefully
        try:
            signal.signal(signal.SIGTERM, self._handle_signal)
        except (OSError, ValueError):
            pass  # Can't set signal handler in some contexts

    def get_path(self, filename: str) -> str:
        """Get a path within the work directory."""
        return os.path.join(self.work_dir, filename)

    def register(self, path: str):
        """Register an external file for cleanup."""
        self._registered.append(path)

    def remove_early(self, path: str):
        """Remove a file immediately (e.g., video after audio extraction)."""
        try:
            if os.path.isfile(path):
                os.remove(path)
                progress(f"  🗑️  Cleaned: {os.path.basename(path)}")
        except OSError:
            pass

    def cleanup(self):
        """Clean up all temporary files."""
        if self._cleaned:
            return
        self._cleaned = True
        # Clean registered external files
        for path in self._registered:
            try:
                if os.path.isfile(path):
                    os.remove(path)
                elif os.path.isdir(path):
                    shutil.rmtree(path, ignore_errors=True)
            except OSError:
                pass
        # Clean work directory
        try:
            shutil.rmtree(self.work_dir, ignore_errors=True)
        except OSError:
            pass

    def _handle_signal(self, sig, frame):
        self.cleanup()
        sys.exit(128 + sig)

    @staticmethod
    def cleanup_orphans(base_dir: str = "/tmp", prefix: str = "vi-", max_age_hours: int = 24):
        """Clean up orphaned temp directories from crashed runs."""
        import time
        now = time.time()
        cutoff = now - (max_age_hours * 3600)
        try:
            for entry in os.listdir(base_dir):
                if entry.startswith(prefix):
                    full = os.path.join(base_dir, entry)
                    if os.path.isdir(full):
                        try:
                            mtime = os.path.getmtime(full)
                            if mtime < cutoff:
                                shutil.rmtree(full, ignore_errors=True)
                        except OSError:
                            pass
        except OSError:
            pass


# ──────────────────────────────────────────────
# Cache
# ──────────────────────────────────────────────

class Cache:
    """File-based transcript cache at ~/.cache/video-insight/"""

    def __init__(self, cache_dir: str = None):
        if cache_dir is None:
            cache_dir = os.path.expanduser("~/.cache/video-insight")
        self.cache_dir = cache_dir
        os.makedirs(self.cache_dir, exist_ok=True)

    def _key(self, platform: str, video_id: str) -> str:
        return f"{platform}_{video_id}"

    def _path(self, key: str) -> str:
        return os.path.join(self.cache_dir, f"{key}.json")

    def get(self, platform: str, video_id: str) -> Optional[dict]:
        """Get cached transcript data. Returns None if not cached."""
        path = self._path(self._key(platform, video_id))
        if os.path.exists(path):
            try:
                with open(path, "r", encoding="utf-8") as f:
                    data = json.load(f)
                data["cached"] = True
                return data
            except (json.JSONDecodeError, OSError):
                return None
        return None

    def put(self, platform: str, video_id: str, data: dict):
        """Cache transcript data (permanent)."""
        key = self._key(platform, video_id)
        path = self._path(key)
        cache_data = {
            "video_id": data.get("video_id", video_id),
            "platform": data.get("platform", platform),
            "title": data.get("title", ""),
            "channel": data.get("channel", ""),
            "duration_seconds": data.get("duration_seconds", 0),
            "transcript": data.get("transcript", ""),
            "transcript_with_timestamps": data.get("transcript_with_timestamps", ""),
            "cached_at": datetime.now(timezone.utc).isoformat(),
        }
        try:
            with open(path, "w", encoding="utf-8") as f:
                json.dump(cache_data, f, ensure_ascii=False, indent=2)
        except OSError as e:
            progress(f"  ⚠️  Cache write failed: {e}")

    def has(self, platform: str, video_id: str) -> bool:
        return os.path.exists(self._path(self._key(platform, video_id)))


path: scripts/youtube.py
content: #!/usr/bin/env python3
"""video-insight YouTube module: metadata + transcript extraction."""

import json
import subprocess
import sys
from typing import Optional, List, Dict

from utils import (
    extract_youtube_id, progress, load_settings, get_setting, ok_result, err_result
)

SETTINGS = load_settings()


# ──────────────────────────────────────────────
# Metadata
# ──────────────────────────────────────────────

def get_video_details(video_id: str) -> Optional[Dict]:
    """Get detailed video metadata using yt-dlp."""
    retries = int(get_setting("yt_dlp_retries", 3, settings=SETTINGS))
    try:
        result = subprocess.run(
            [
                "yt-dlp",
                "--no-warnings",
                "-j",
                "--no-download",
                "--retries", str(retries),
                f"https://www.youtube.com/watch?v={video_id}",
            ],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if result.returncode != 0:
            return None

        data = json.loads(result.stdout)
        duration = data.get("duration", 0)

        return {
            "title": data.get("title", "Unknown"),
            "channel": data.get("channel", data.get("uploader", "Unknown")),
            "duration_seconds": duration,
            "duration_display": f"{duration // 60}:{duration % 60:02d}",
            "description": data.get("description", "")[:1000],
            "published": data.get("upload_date", ""),
            "view_count": data.get("view_count", 0),
            "like_count": data.get("like_count", 0),
        }
    except (subprocess.TimeoutExpired, Exception) as e:
        progress(f"  ⚠️  get_video_details failed: {e}")
        return None


# ──────────────────────────────────────────────
# Transcript: innertube → youtube-transcript-api fallback
# ──────────────────────────────────────────────

def _parse_caption_xml(xml_text: str) -> List[dict]:
    """Parse YouTube caption XML into segments with timestamps."""
    import xml.etree.ElementTree as ET
    import html as html_mod

    segments = []
    try:
        root = ET.fromstring(xml_text)
        # Try <p> format (innertube)
        for p in root.findall(".//p"):
            start_ms = int(p.get("t", 0))
            dur_ms = int(p.get("d", 0))
            words = []
            for s in p.findall("s"):
                if s.text:
                    words.append(html_mod.unescape(s.text.strip()))
            text = " ".join(words) if words else (html_mod.unescape(p.text.strip()) if p.text else "")
            if text:
                segments.append({
                    "start": start_ms / 1000.0,
                    "end": (start_ms + dur_ms) / 1000.0,
                    "text": text,
                })

        # Fallback: <text> format
        if not segments:
            for elem in root.findall(".//text"):
                if elem.text:
                    start = float(elem.get("start", 0))
                    dur = float(elem.get("dur", 0))
                    segments.append({
                        "start": start,
                        "end": start + dur,
                        "text": html_mod.unescape(elem.text.strip()),
                    })
    except Exception:
        pass
    return segments


def _download_caption(url: str) -> Optional[str]:
    try:
        import requests
        r = requests.get(url, timeout=15)
        if r.status_code == 200 and r.text.strip():
            return r.text
    except Exception:
        pass
    return None


def _get_transcript_innertube(video_id: str) -> Optional[List[dict]]:
    """Get transcript via innertube API."""
    try:
        import innertube

        client = innertube.InnerTube("ANDROID")
        data = client.player(video_id=video_id)

        if "captions" not in data:
            return None

        caps = data["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]
        if not caps:
            return None

        # Pick best caption track
        cap_url = None
        for prefer in ["en", "zh-Hans", "zh", "zh-Hant", "ja", "ko"]:
            for c in caps:
                if c.get("languageCode") == prefer:
                    cap_url = c["baseUrl"]
                    break
            if cap_url:
                break
        if not cap_url:
            cap_url = caps[0]["baseUrl"]

        xml_text = _download_caption(cap_url)
        if not xml_text:
            return None

        segments = _parse_caption_xml(xml_text)
        if not segments:
            return None

        # Minimum length check
        full_text = " ".join(s["text"] for s in segments)
        min_len = int(get_setting("transcript_min_length", 50, settings=SETTINGS))
        return segments if len(full_text) > min_len else None

    except Exception as e:
        progress(f"  ⚠️  innertube failed: {e}")
        return None


def _get_transcript_ytapi(video_id: str) -> Optional[List[dict]]:
    """Get transcript via youtube-transcript-api."""
    try:
        from youtube_transcript_api import YouTubeTranscriptApi

        api = YouTubeTranscriptApi()
        fetched = api.fetch(video_id, languages=["zh-Hans", "zh-Hant", "en", "ja", "ko"])

        segments = []
        for item in fetched:
            text = item.text if hasattr(item, "text") else item.get("text", "")
            start = item.start if hasattr(item, "start") else item.get("start", 0)
            duration = item.duration if hasattr(item, "duration") else item.get("duration", 0)
            if text:
                segments.append({
                    "start": float(start),
                    "end": float(start) + float(duration),
                    "text": text,
                })

        full_text = " ".join(s["text"] for s in segments)
        min_len = int(get_setting("transcript_min_length", 50, settings=SETTINGS))
        return segments if len(full_text) > min_len else None

    except Exception as e:
        progress(f"  ⚠️  youtube-transcript-api failed: {e}")
        return None


def get_transcript(video_id: str) -> Optional[List[dict]]:
    """Get video transcript using innertube → ytapi fallback.
    Returns list of {start, end, text} segments.
    """
    progress("  📝 Fetching transcript (innertube)...")
    segments = _get_transcript_innertube(video_id)
    if segments:
        progress(f"  ✅ innertube: {len(segments)} segments")
        return segments

    progress("  📝 Fetching transcript (youtube-transcript-api)...")
    segments = _get_transcript_ytapi(video_id)
    if segments:
        progress(f"  ✅ ytapi: {len(segments)} segments")
        return segments

    return None


# ──────────────────────────────────────────────
# Channel scan
# ──────────────────────────────────────────────

DEFAULT_MIN_DURATION = 300  # 5 minutes — filter Shorts


def get_channel_videos(channel_id: str, hours: int = 24, max_videos: int = 5) -> List[Dict]:
    """Get recent videos from a YouTube channel using yt-dlp."""
    if channel_id.startswith("UC") and len(channel_id) == 24:
        url = f"https://www.youtube.com/channel/{channel_id}/videos"
    elif channel_id.startswith("http"):
        url = channel_id.rstrip("/") + "/videos"
    else:
        url = f"https://www.youtube.com/@{channel_id}/videos"

    try:
        result = subprocess.run(
            [
                "yt-dlp",
                "--flat-playlist",
                "--no-warnings",
                "-J",
                "--playlist-end", str(max_videos * 2),
                url,
            ],
            capture_output=True,
            text=True,
            timeout=45,
        )
        if result.returncode != 0:
            progress(f"⚠️ yt-dlp error for {channel_id}: {result.stderr[:100]}")
            return []

        data = json.loads(result.stdout)
        entries = data.get("entries", [])
        videos = []

        for entry in entries:
            if not entry:
                continue
            video_id = entry.get("id")
            if not video_id:
                continue
            if entry.get("duration") and entry.get("duration") < DEFAULT_MIN_DURATION:
                continue
            videos.append({
                "id": video_id,
                "title": entry.get("title", "Unknown"),
                "channel": entry.get("channel", entry.get("uploader", "Unknown")),
                "url": f"https://www.youtube.com/watch?v={video_id}",
                "duration_hint": entry.get("duration"),
            })
            if len(videos) >= max_videos:
                break

        return videos
    except Exception as e:
        progress(f"⚠️ Error fetching channel {channel_id}: {e}")
        return []


# ──────────────────────────────────────────────
# Main YouTube processor
# ──────────────────────────────────────────────

def process_youtube(video_id: str, cache=None, no_cache: bool = False, extract_frames: bool = False) -> dict:
    """Process a YouTube video. Returns unified result dict."""
    progress(f"📹 YouTube: {video_id}")

    # Check cache first
    if cache and not no_cache:
        cached = cache.get("youtube", video_id)
        if cached:
            progress("  ✅ Cache hit")
            return cached

    # Get metadata
    progress("  📋 Fetching metadata...")
    details = get_video_details(video_id)
    if not details:
        return err_result(f"Failed to fetch video details for {video_id}", "METADATA_ERROR")

    # Get transcript
    segments = get_transcript(video_id)
    if not segments:
        return err_result("No transcript available (no captions found)", "NO_TRANSCRIPT")

    transcript_plain = " ".join(s["text"] for s in segments)
    transcript_with_ts = "\n".join(f"[{s['start']:.1f}-{s['end']:.1f}] {s['text']}" for s in segments)

    result = {
        "video_id": video_id,
        "platform": "youtube",
        "title": details["title"],
        "channel": details["channel"],
        "duration_seconds": details["duration_seconds"],
        "transcript": transcript_plain,
        "transcript_with_timestamps": transcript_with_ts,
        "frames": [],
        "cached": False,
    }

    # Cache the result
    if cache:
        cache.put("youtube", video_id, result)
        progress("  💾 Cached")

    progress(f"  ✅ Done: {len(transcript_plain)} chars")
    return result


path: setup.sh
content: #!/usr/bin/env bash
# video-insight setup — non-interactive by default, --interactive for prompts
# Supports: macOS / Linux / WSL / Windows VM
set -euo pipefail

SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
VENV_DIR="${SKILL_DIR}/.venv"
INTERACTIVE=false

for arg in "$@"; do
    case "$arg" in
        --interactive) INTERACTIVE=true ;;
        --help|-h) echo "Usage: setup.sh [--interactive]"; exit 0 ;;
    esac
done

log()  { echo "[setup] $*" >&2; }
warn() { echo "[setup] ⚠️  $*" >&2; }
ok()   { echo "[setup] ✅ $*" >&2; }

# ── Platform detection ──
OS="$(uname -s)"
ARCH="$(uname -m)"
IS_WSL=false
IS_MACOS=false
IS_LINUX=false

case "$OS" in
    Linux)
        IS_LINUX=true
        if grep -qi microsoft /proc/version 2>/dev/null; then
            IS_WSL=true
        fi
        ;;
    Darwin) IS_MACOS=true ;;
    MINGW*|MSYS*|CYGWIN*)
        warn "Native Windows detected. Recommend running under WSL."
        IS_LINUX=true
        ;;
    *) warn "Unknown OS: $OS. Proceeding anyway." ;;
esac

log "Platform: $OS ($ARCH) | WSL=$IS_WSL | macOS=$IS_MACOS"

# ── Python check ──
PYTHON=""
for py in python3 python; do
    if command -v "$py" >/dev/null 2>&1; then
        PYTHON="$py"
        break
    fi
done

if [ -z "$PYTHON" ]; then
    echo "❌ Python 3 not found. Please install Python 3.8+ first." >&2
    exit 1
fi

PY_VERSION=$("$PYTHON" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
log "Python: $PYTHON ($PY_VERSION)"

PY_MAJOR=$("$PYTHON" -c "import sys; print(sys.version_info.major)")
PY_MINOR=$("$PYTHON" -c "import sys; print(sys.version_info.minor)")
if [ "$PY_MAJOR" -lt 3 ] || { [ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -lt 8 ]; }; then
    echo "❌ Python 3.8+ required, found $PY_VERSION" >&2
    exit 1
fi

# ── ffmpeg check ──
if ! command -v ffmpeg >/dev/null 2>&1; then
    warn "ffmpeg not found. Bilibili video processing requires ffmpeg."
    if $IS_MACOS; then
        log "Install: brew install ffmpeg"
    elif $IS_LINUX; then
        log "Install: sudo apt install ffmpeg (or equivalent)"
    fi
    if $INTERACTIVE; then
        read -rp "Continue without ffmpeg? [y/N] " ans
        [[ "$ans" =~ ^[Yy] ]] || exit 1
    else
        warn "Continuing without ffmpeg. Bilibili support will be limited."
    fi
else
    ok "ffmpeg found: $(ffmpeg -version 2>&1 | head -1)"
fi

# ── Create venv ──
if [ ! -d "$VENV_DIR" ]; then
    log "Creating virtual environment..."
    "$PYTHON" -m venv "$VENV_DIR" 2>/dev/null || {
        warn "venv creation failed. Trying without ensurepip..."
        "$PYTHON" -m venv --without-pip "$VENV_DIR" 2>/dev/null || {
            warn "venv not available. Will use system Python with --break-system-packages."
            VENV_DIR=""
        }
    }
fi

if [ -n "$VENV_DIR" ] && [ -f "${VENV_DIR}/bin/python3" ]; then
    PIP="${VENV_DIR}/bin/pip"
    VPYTHON="${VENV_DIR}/bin/python3"
    ok "venv ready: $VENV_DIR"
elif [ -n "$VENV_DIR" ] && [ -f "${VENV_DIR}/Scripts/pip.exe" ]; then
    PIP="${VENV_DIR}/Scripts/pip.exe"
    VPYTHON="${VENV_DIR}/Scripts/python.exe"
    ok "venv ready (Windows): $VENV_DIR"
else
    PIP="pip3"
    VPYTHON="$PYTHON"
    warn "No venv — installing to user/system Python"
fi

# Ensure pip exists in venv
if [ -n "$VENV_DIR" ] && ! command -v "$PIP" >/dev/null 2>&1; then
    "$VPYTHON" -m ensurepip --upgrade 2>/dev/null || true
fi

# ── Pip install helper ──
pip_install() {
    if [ -n "$VENV_DIR" ]; then
        "$PIP" install --quiet "$@" 2>/dev/null || "$PIP" install "$@"
    else
        "$PIP" install --quiet --break-system-packages "$@" 2>/dev/null || \
        "$PIP" install --quiet --user "$@" 2>/dev/null || \
        "$PIP" install "$@"
    fi
}

# ── Core dependencies ──
log "Installing core dependencies..."
pip_install yt-dlp youtube-transcript-api innertube requests

ok "Core dependencies installed"

# ── GPU detection and whisper ──
GPU_AVAILABLE=false
GPU_INFO=""

if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1; then
    GPU_INFO=$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null || echo "unknown")
    GPU_AVAILABLE=true
fi

log ""
log "═══════════════════════════════════════════════"
if $GPU_AVAILABLE; then
    log "🎮 GPU detected: $GPU_INFO"
    log "  Whisper will use CUDA (faster transcription)"
    log "  Installing faster-whisper with CUDA support..."
    pip_install faster-whisper
    # Verify CUDA actually works with ctranslate2
    if "$VPYTHON" -c "import ctranslate2; ctranslate2.get_supported_compute_types('cuda')" >/dev/null 2>&1; then
        ok "faster-whisper with CUDA ready"
    else
        warn "ctranslate2 CUDA not functional. Whisper will fallback to CPU."
        warn "Possible causes:"
        warn "  - Missing CUDA libraries (libcublas, libcudnn)"
        warn "  - CUDA version mismatch"
        warn "  - WSL CUDA driver not properly configured"
        log "  Whisper will still work, just slower (CPU mode)."
    fi
else
    log "💻 No GPU detected — Whisper will use CPU mode"
    log "  Bilibili transcription will be slower but functional."
    if $IS_WSL; then
        log "  WSL tip: ensure NVIDIA drivers are installed on Windows host"
        log "  and nvidia-smi is accessible from WSL."
    fi
    log "  Installing faster-whisper (CPU)..."
    pip_install faster-whisper
    ok "faster-whisper (CPU) ready"
fi
log "═══════════════════════════════════════════════"
log ""

# ── Create cache directory ──
CACHE_DIR="${HOME}/.cache/video-insight"
mkdir -p "$CACHE_DIR"
ok "Cache directory: $CACHE_DIR"

# ── Verify installation ──
log "Verifying installation..."
VERIFY_OK=true

for mod in yt_dlp youtube_transcript_api innertube requests; do
    if "$VPYTHON" -c "import $mod" 2>/dev/null; then
        ok "$mod"
    else
        warn "Failed to import $mod"
        VERIFY_OK=false
    fi
done

# faster-whisper is optional
if "$VPYTHON" -c "import faster_whisper" 2>/dev/null; then
    ok "faster-whisper"
else
    warn "faster-whisper not available (Bilibili transcription won't work)"
fi

if $VERIFY_OK; then
    log ""
    ok "Setup complete! Run: video-insight --url 'https://...'"
else
    warn "Some dependencies failed. Check errors above."
    exit 1
fi


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)