Skip to content
Back to skills

Video Rag

ASecurity

Video ingestion for multimodal RAG. Covers keyframe extraction (ffmpeg, PySceneDetect), audio-track transcription via Whisper, visual embeddings per keyframe (CLIP, SigLIP, VoyageAI multimodal), multi-modal retrieval combining visual + transcript hits, timestamp-anchored citations, and YouTube transcript ingestion. USE WHEN: user mentions "video RAG", "video transcription", "keyframe extraction", "PySceneDetect", "ffmpeg scenes", "CLIP embeddings", "SigLIP", "VoyageAI multimodal", "YouTube t...

  • 31 stars
  • 0 votes
  • 0 copies
  • 7 views
  • Added September 8, 2026
ai-agentspythongobashapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 8, 2026

npx -y skills add claude-dev-suite/claude-dev-suite --skill video-rag --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Video Rag?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Video Rag
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/claude-dev-suite-video-rag/badge)](https://www.skillsdirectory.com/skills/claude-dev-suite-video-rag)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: video-rag
description: |
  Video ingestion for multimodal RAG. Covers keyframe extraction (ffmpeg,
  PySceneDetect), audio-track transcription via Whisper, visual embeddings
  per keyframe (CLIP, SigLIP, VoyageAI multimodal), multi-modal retrieval
  combining visual + transcript hits, timestamp-anchored citations, and
  YouTube transcript ingestion.

  USE WHEN: user mentions "video RAG", "video transcription", "keyframe
  extraction", "PySceneDetect", "ffmpeg scenes", "CLIP embeddings", "SigLIP",
  "VoyageAI multimodal", "YouTube transcript", "multimodal retrieval",
  "video chunking"

  DO NOT USE FOR: audio-only content (podcasts, meetings) - use `audio-transcription`;
  screen recordings that are effectively slide decks - use `office-docs` on the source PPTX;
  static images - use `ocr` or pure vision embeddings
allowed-tools: Read, Grep, Glob, Write, Edit
---
# Video RAG

## Pipeline Overview

```
video.mp4
   |-- ffmpeg demux --------> audio.wav  --> Whisper --> transcript (timestamped segments)
   |                                                       |
   |                                                       v
   |                                         speaker-aware chunks
   |
   |-- PySceneDetect -------> scene list --> ffmpeg extract keyframes --> images
                                                                            |
                                                                            v
                                              CLIP / SigLIP / VoyageAI multimodal embeddings

Index:  text chunks (+ time) + image chunks (+ time) in vector store with shared schema.
Query:  embed text -> search text index AND multimodal index -> merge -> answer with timestamp.
```

## Keyframe Extraction

### ffmpeg — Fixed Interval

```bash
# One frame every 5 seconds, scaled to 512px wide
ffmpeg -i input.mp4 -vf "fps=1/5,scale=512:-1" -q:v 2 frames/f_%05d.jpg
```

### PySceneDetect — Shot Boundaries

```python
from scenedetect import open_video, SceneManager
from scenedetect.detectors import ContentDetector, AdaptiveDetector
from scenedetect.scene_manager import save_images

video = open_video("input.mp4")
mgr = SceneManager()
mgr.add_detector(ContentDetector(threshold=27.0))   # or AdaptiveDetector()
mgr.detect_scenes(video)

scenes = mgr.get_scene_list()
for i, (start, end) in enumerate(scenes):
    print(i, start.get_seconds(), end.get_seconds())

save_images(
    scenes,
    video,
    num_images=1,          # 1 representative frame per scene
    image_name_template="scene-$SCENE_NUMBER",
    output_dir="keyframes",
)
```

### ffmpeg — Scene-Change Detection (no Python)

```bash
# Dump frames at scene cuts with timestamps
ffmpeg -i input.mp4 -vf "select='gt(scene,0.3)',showinfo" \
       -vsync vfr keyframes/kf_%04d.jpg 2> scene-log.txt
```

## Audio Track Extraction

```python
import subprocess

def extract_audio(video_path: str, wav_path: str) -> None:
    # 16 kHz mono WAV - ideal for Whisper
    subprocess.run([
        "ffmpeg", "-y", "-i", video_path,
        "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le",
        wav_path,
    ], check=True)
```

## Whisper Transcription

See `audio-transcription` skill for the full engine comparison. Minimal example:

```python
from faster_whisper import WhisperModel

model = WhisperModel("large-v3", device="cuda", compute_type="float16")
segments, info = model.transcribe("audio.wav", vad_filter=True,
                                  word_timestamps=True)

transcript_segments = [
    {"start": s.start, "end": s.end, "text": s.text.strip()}
    for s in segments
]
```

## Visual Embeddings

### CLIP (OpenAI / open_clip)

```python
import torch
from PIL import Image
import open_clip

device = "cuda" if torch.cuda.is_available() else "cpu"
model, _, preprocess = open_clip.create_model_and_transforms(
    "ViT-L-14", pretrained="openai"
)
model = model.to(device).eval()
tokenizer = open_clip.get_tokenizer("ViT-L-14")

def embed_image(path: str) -> list[float]:
    img = preprocess(Image.open(path).convert("RGB")).unsqueeze(0).to(device)
    with torch.no_grad():
        vec = model.encode_image(img)
        vec = vec / vec.norm(dim=-1, keepdim=True)
    return vec[0].cpu().tolist()

def embed_text(text: str) -> list[float]:
    tokens = tokenizer([text]).to(device)
    with torch.no_grad():
        vec = model.encode_text(tokens)
        vec = vec / vec.norm(dim=-1, keepdim=True)
    return vec[0].cpu().tolist()
```

### SigLIP (better zero-shot than CLIP)

```python
from transformers import AutoProcessor, AutoModel
import torch

processor = AutoProcessor.from_pretrained("google/siglip-so400m-patch14-384")
model = AutoModel.from_pretrained("google/siglip-so400m-patch14-384").to("cuda").eval()

def embed_image_siglip(path: str) -> list[float]:
    inputs = processor(images=Image.open(path).convert("RGB"), return_tensors="pt").to("cuda")
    with torch.no_grad():
        feats = model.get_image_features(**inputs)
        feats = feats / feats.norm(dim=-1, keepdim=True)
    return feats[0].cpu().tolist()
```

### VoyageAI Multimodal (API)

```python
import os, base64, voyageai

vo = voyageai.Client(api_key=os.environ["VOYAGE_API_KEY"])

def encode_image_b64(path: str) -> str:
    with open(path, "rb") as f:
        return "data:image/jpeg;base64," + base64.b64encode(f.read()).decode()

result = vo.multimodal_embed(
    inputs=[[encode_image_b64("keyframes/scene-0001.jpg"), "a laptop on a desk"]],
    model="voyage-multimodal-3",
    input_type="document",
)
vectors = result.embeddings
```

## Unified Chunk Schema

```python
from dataclasses import dataclass
from typing import Literal

@dataclass
class VideoChunk:
    source: str
    modality: Literal["transcript", "visual"]
    start: float
    end: float
    text: str | None           # transcript text or caption
    image_path: str | None     # keyframe on disk or blob URL
    vector: list[float]
    metadata: dict             # speaker, scene_id, etc.
```

Both modalities share the schema so they can live in the same index (with a `modality` filter) or in two parallel indexes merged at query time.

## Chunking + Embedding

```python
def build_video_chunks(video_path: str) -> list[VideoChunk]:
    # 1. Audio
    extract_audio(video_path, "audio.wav")
    model = WhisperModel("large-v3", device="cuda", compute_type="float16")
    segments, info = model.transcribe("audio.wav", vad_filter=True,
                                      word_timestamps=True)
    transcript_segments = [
        {"start": s.start, "end": s.end, "text": s.text.strip()}
        for s in segments
    ]

    # 2. Scenes + keyframes
    video = open_video(video_path)
    mgr = SceneManager()
    mgr.add_detector(ContentDetector(threshold=27.0))
    mgr.detect_scenes(video)
    scenes = mgr.get_scene_list()
    save_images(scenes, video, num_images=1,
                image_name_template="$SCENE_NUMBER",
                output_dir="keyframes")

    # 3. Text chunks
    from openai import OpenAI
    oa = OpenAI()
    text_chunks = []
    for seg in merge_into_windows(transcript_segments, max_chars=900):
        emb = oa.embeddings.create(model="text-embedding-3-large",
                                   input=seg["text"]).data[0].embedding
        text_chunks.append(VideoChunk(
            source=video_path, modality="transcript",
            start=seg["start"], end=seg["end"],
            text=seg["text"], image_path=None,
            vector=emb, metadata={"language": info.language},
        ))

    # 4. Visual chunks
    visual_chunks = []
    for i, (start, end) in enumerate(scenes, start=1):
        img_path = f"keyframes/{i:03d}.jpg"
        vec = embed_image(img_path)
        visual_chunks.append(VideoChunk(
            source=video_path, modality="visual",
            start=start.get_seconds(), end=end.get_seconds(),
            text=None, image_path=img_path,
            vector=vec, metadata={"scene_id": i},
        ))
    return text_chunks + visual_chunks
```

## Multi-Modal Retrieval

Two strategies:

**1. Shared CLIP-space index** (requires CLIP text embeddings for text chunks too, or caption the transcript first). Query text -> embed with CLIP text encoder -> single search.

**2. Dual-index fusion** (recommended): text chunks in a text-embedding space, visual chunks in CLIP/SigLIP space. Query both, combine scores.

```python
def hybrid_search(query: str, k: int = 10) -> list[VideoChunk]:
    # Text side
    text_vec = oa.embeddings.create(
        model="text-embedding-3-large", input=query
    ).data[0].embedding
    text_hits = text_index.query(text_vec, k=k)

    # Visual side
    vis_vec = embed_text(query)   # CLIP text encoder
    vis_hits = visual_index.query(vis_vec, k=k)

    # Reciprocal Rank Fusion
    def rrf(ranks: list[list], k_rrf: int = 60) -> dict:
        scores = {}
        for results in ranks:
            for rank, item in enumerate(results):
                scores[item.id] = scores.get(item.id, 0) + 1 / (k_rrf + rank)
        return scores

    fused = rrf([text_hits, vis_hits])
    return sorted(fused, key=fused.get, reverse=True)[:k]
```

## Timestamp-Anchored Citations

```python
def citation(chunk: VideoChunk) -> str:
    h, rem = divmod(int(chunk.start), 3600)
    m, s = divmod(rem, 60)
    stamp = f"{h:02d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
    return f"{chunk.source} @ {stamp} ({chunk.modality})"

# YouTube deep link
def youtube_link(video_id: str, start: float) -> str:
    return f"https://youtu.be/{video_id}?t={int(start)}"
```

## YouTube Transcripts

```python
from youtube_transcript_api import YouTubeTranscriptApi

def fetch_youtube(video_id: str) -> list[dict]:
    # Try manual English, fall back to auto, then any available
    api = YouTubeTranscriptApi()
    listing = api.list(video_id)
    try:
        tx = listing.find_manually_created_transcript(["en"])
    except Exception:
        tx = listing.find_generated_transcript(["en"])
    return [
        {"start": s.start, "end": s.start + s.duration, "text": s.text}
        for s in tx.fetch()
    ]
```

For videos without captions, download with `yt-dlp` then run the Whisper pipeline:

```bash
yt-dlp -f "bestaudio[ext=m4a]" -x --audio-format wav -o "audio.%(ext)s" <url>
```

## Anti-Patterns

| Anti-Pattern | Fix |
|---|---|
| Fixed-interval keyframes on static videos | Use PySceneDetect so each scene is represented once |
| Only transcript, no visuals | A "how do I click the button" query needs visual retrieval too |
| CLIP on huge frames | Resize to 224-384 px per model spec before embedding |
| Storing raw frames in the vector DB | Keep frames on object storage; store URI + vector |
| Ignoring scene boundaries when chunking transcript | Align transcript windows to scene start/end when possible |
| One giant vector per video | Always chunk; the unit of retrieval is seconds, not minutes |
| Mixing CLIP-space and text-embedding-space in one cosine search | Either share space or use RRF fusion |
| Scraping YouTube pages for transcripts | Use `youtube-transcript-api` / `yt-dlp` first |

## Production Checklist

- [ ] Scene-aware keyframes (PySceneDetect) rather than fixed intervals for edited content
- [ ] 16 kHz mono WAV extracted once and cached
- [ ] Transcript and visual chunks share `{source, start, end, modality}` schema
- [ ] Visual model choice documented (CLIP vs SigLIP vs VoyageAI multimodal)
- [ ] Dual-index fusion (RRF) unless using a true shared multimodal space
- [ ] Keyframes stored on object storage, not in the vector DB
- [ ] Citations include deep-linkable timestamps (YouTube `?t=` or local `#t=`)
- [ ] Whisper VAD enabled; language auto-detected and stored
- [ ] Captions path (`youtube-transcript-api`) tried before full Whisper transcription
- [ ] Re-ingest triggered by source hash change, not by re-downloads

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.