How to Build an AI Video Assistant That Watches Recordings and Answers Questions
Learn how to build a multimodal AI video assistant in Python using PySceneDetect, OpenCV, Faster-Whisper, Vision LLMs, and Qdrant timestamp retrieval.
Executive summary
How to Build: How to Build an AI Video Assistant That Watches Recordings and Answers Questions (Summary & Mechanics)
Learn how to build a multimodal AI video assistant in Python using PySceneDetect, OpenCV, Faster-Whisper, Vision LLMs, and Qdrant timestamp retrieval. This system couples deterministic validation loops with modern AI orchestration to eliminate hallucinations, enforce type contracts, and guarantee enterprise production stability.
- Architecture
- Deterministic Agent Graph
- Primary Stack
- Python 3.12 / FastAPI / Pydantic
- Part Series
- Part II: Voice AI and Multimodal Applications
- Verified As Of
- October 2026
Who This Guide Is For
- Production automation requiring verifiable citation & error boundaries
- Self-hosted privacy-first deployments with open-source alternatives
Important Considerations
- Enforce hard recursion limits to prevent cyclic execution drift
- Sanitize untrusted inputs to prevent indirect prompt injection
Introduction
To build an AI video assistant that watches 60-minute lectures, product demos, security recordings, or engineering screen-shares and answers questions with exact [MM:SS] playable timestamps, you cannot sample every video frame at 30 frames per second (FPS). A 1-hour 1080p video at 30 FPS contains 108,000 frames—passing all of them to a Vision-Language Model (VLM) would consume over 80 million image tokens (~$200+ per video). Instead, an efficient multimodal video pipeline combines content-aware scene boundary detection (PySceneDetect / perceptual frame hashing) to extract only distinct visual keyframes (reducing 108,000 frames to ~120–250 keyframes), word-timestamped audio transcription (Faster-Whisper), and temporally aligned multimodal chunks indexed in Qdrant or pgvector.
Multimodal Video Ingestion & Timestamp Retrieval Architecture
flowchart TD
Video([Input Video .mp4 / .mkv]) --> Demux[FFmpeg Audio/Video Demultiplexer]
Demux --> AudioTrack[16kHz Audio Track]
Demux --> VideoTrack[Video Frame Stream]
AudioTrack --> Whisper[Faster-Whisper Word-Timestamp STT]
VideoTrack --> SceneDetect[PySceneDetect + Perceptual Diff Keyframe Extractor]
SceneDetect --> VLM[Vision-Language Model: Frame OCR & Visual Captioning]
Whisper --> Aligner[Temporal Window Merger: 30s Segments + Keyframe Captions]
VLM --> Aligner
Aligner --> VectorIndex[(Qdrant / pgvector Multimodal Timestamp Index)]
UserQ([User Question]) --> HybridQuery[Retrieve Top-K Timestamp Windows + Inspect High-Res Frames]
VectorIndex --> HybridQuery
HybridQuery --> Answer([Answer with Exact [MM:SS - MM:SS] Deep Links])Why Content-Aware Scene Detection Beats Uniform 1-FPS Sampling
In a recorded webinar, coding tutorial, or slide presentation, a speaker may stay on the same architecture slide for 4 minutes (generating 240 identical frames at 1 FPS) and then rapidly flip through 3 terminal windows in 5 seconds. Content-aware scene detection (computing mean pixel HSV/luminance delta $\Delta_{\text{frame}} \ge \theta$ combined with a maximum interval cap of 15 seconds) captures every slide transition and code change while eliminating 98% of redundant duplicate frames.
Technology Comparison: Paid vs. Open-Source Video Stack
| Component | Commercial / Cloud API | Open-Source / Self-Hosted Alternative | Self-Hosting Possible? | Technical & Storage Notes |
|---|---|---|---|---|
| Scene & Keyframe Extraction | AWS Rekognition Video / Google Video Intelligence | PySceneDetect (https://www.google.com/search?q=https://github.com/Breakthrough/PySceneDetect) (BSD-3-Clause) + OpenCV (opencv-python, Apache-2.0) + FFmpeg | Yes (runs fast on CPU) | Resize extracted keyframes to max 1024px long edge before VLM inference to reduce image token cost by 60%. |
| Audio Transcription | Deepgram Nova-3 (https://deepgram.com/pricing) | Faster-Whisper (https://github.com/SYSTRAN/faster-whisper) (MIT) | Yes | Aligns spoken explanations with visual slide changes. |
| Vision-Language Model (VLM) | Gemini 2.5 Pro / OpenAI gpt-4o-mini | Ollama (https://github.com/ollama/ollama) (Qwen2.5-VL-7B / Qwen2.5-VL-32B / Gemma 4) | Yes | Qwen2.5-VL excels at reading dense terminal text and slide diagrams inside video frames. |
| Timestamp Vector Index | Pinecone / TwelveLabs | Qdrant (https://github.com/qdrant/qdrant) (Apache-2.0) / pgvector (https://github.com/pgvector/pgvector) | Yes | Store video_id, start_sec, end_sec, and keyframe_path in the vector payload. |
Recommended Implementation Options
Option A: Best Practical Implementation (Recommended): Python 3.12 + OpenCV/PySceneDetect (https://www.google.com/search?q=https://github.com/Breakthrough/PySceneDetect) + Faster-Whisper (https://github.com/SYSTRAN/faster-whisper) + VLM frame captioning (gpt-4o-mini or Qwen2.5-VL) + Qdrant (https://github.com/qdrant/qdrant) timestamp retrieval.
Option B: Fully Open-Source Local Implementation: OpenCV + PySceneDetect (https://www.google.com/search?q=https://github.com/Breakthrough/PySceneDetect) + Faster-Whisper (https://github.com/SYSTRAN/faster-whisper) + local Ollama (https://github.com/ollama/ollama) (qwen2.5-vl) + Qdrant (https://github.com/qdrant/qdrant).
Option C: Production Video Knowledge Platform: FastAPI + S3/CloudFront HLS streaming + Celery GPU ingestion workers + Qdrant hybrid dense/sparse search.
Step-by-Step Implementation Guide
Step 1: Project Structure
ai-video-assistant/
├── app/
│ ├── __init__.py
│ ├── keyframe_selector.py
│ └── multimodal_aligner.py
├── tests/
│ └── test_video_assistant.py
└── requirements.txt
Step 2: Implement Perceptual Scene Change Keyframe Selection (app/keyframe_selector.py)
from typing import List
import numpy as np
from pydantic import BaseModel
class ExtractedKeyframe(BaseModel):
frame_index: int
timestamp_sec: float
mean_diff_score: float
def select_scene_keyframes_from_luma(
frame_luma_arrays: List[np.ndarray],
fps: float,
diff_threshold: float = 18.0,
min_interval_sec: float = 1.5,
max_interval_sec: float = 15.0,
) -> List[ExtractedKeyframe]:
"""Selects keyframes when visual scene change exceeds diff_threshold or max_interval_sec elapses."""
if not frame_luma_arrays or fps <= 0:
return []
selected: List[ExtractedKeyframe] = [
ExtractedKeyframe(frame_index=0, timestamp_sec=0.0, mean_diff_score=100.0)
]
last_selected_frame = frame_luma_arrays[0].astype(np.float32)
last_ts = 0.0
for idx in range(1, len(frame_luma_arrays)):
curr_ts = round(idx / fps, 2)
elapsed = curr_ts - last_ts
if elapsed < min_interval_sec:
continue
curr_arr = frame_luma_arrays[idx].astype(np.float32)
mean_diff = float(np.mean(np.abs(curr_arr - last_selected_frame)))
if mean_diff >= diff_threshold or elapsed >= max_interval_sec:
selected.append(
ExtractedKeyframe(
frame_index=idx,
timestamp_sec=curr_ts,
mean_diff_score=round(mean_diff, 2),
)
)
last_selected_frame = curr_arr
last_ts = curr_ts
return selected
Step 3: Align Audio Transcripts and Visual Captions into Timestamped RAG Chunks (app/multimodal_aligner.py)
from typing import List
from pydantic import BaseModel
class TranscriptSegment(BaseModel):
start_sec: float
end_sec: float
text: str
class VisualFrameCaption(BaseModel):
timestamp_sec: float
visual_description: str
on_screen_ocr_text: str = ""
class MultimodalVideoChunk(BaseModel):
video_id: str
start_sec: float
end_sec: float
formatted_time_range: str
combined_search_text: str
def format_mm_ss(seconds: float) -> str:
mins = int(seconds // 60)
secs = int(seconds % 60)
return f"{mins:02d}:{secs:02d}"
def build_multimodal_video_chunks(
video_id: str,
transcripts: List[TranscriptSegment],
visual_captions: List[VisualFrameCaption],
window_sec: float = 30.0,
) -> List[MultimodalVideoChunk]:
"""Merges spoken transcript segments and visual frame descriptions into unified 30s retrieval windows."""
max_time = max(
max((t.end_sec for t in transcripts), default=0.0),
max((v.timestamp_sec for v in visual_captions), default=0.0),
)
if max_time <= 0:
return []
chunks: List[MultimodalVideoChunk] = []
curr_start = 0.0
while curr_start < max_time:
curr_end = curr_start + window_sec
window_speech = [
t.text.strip()
for t in transcripts
if t.end_sec > curr_start and t.start_sec < curr_end
]
window_visuals = [
f"[{format_mm_ss(v.timestamp_sec)} Screen: {v.visual_description} | OCR: {v.on_screen_ocr_text}]"
for v in visual_captions
if curr_start <= v.timestamp_sec < curr_end
]
if window_speech or window_visuals:
time_label = f"[{format_mm_ss(curr_start)} - {format_mm_ss(curr_end)}]"
combined = (
f"Video Timestamp {time_label}\n"
f"Spoken Audio: {' '.join(window_speech) or 'No speech'}\n"
f"Visual Scene: {' ; '.join(window_visuals) or 'Static scene'}"
)
chunks.append(
MultimodalVideoChunk(
video_id=video_id,
start_sec=curr_start,
end_sec=curr_end,
formatted_time_range=time_label,
combined_search_text=combined,
)
)
curr_start = curr_end
return chunks
Testing the Video Assistant (tests/test_video_assistant.py)
import numpy as np
from app.keyframe_selector import select_scene_keyframes_from_luma
from app.multimodal_aligner import (
TranscriptSegment,
VisualFrameCaption,
build_multimodal_video_chunks,
)
def test_scene_keyframe_deduplication_and_transition_detection():
# 10 frames at 1 FPS: frames 0..4 are dark (luma=10), frames 5..9 switch to bright slide (luma=200)
frames = [np.full((16, 16), 10, dtype=np.uint8) for _ in range(5)] + [
np.full((16, 16), 200, dtype=np.uint8) for _ in range(5)
]
keyframes = select_scene_keyframes_from_luma(frames, fps=1.0, diff_threshold=25.0, min_interval_sec=1.0)
assert len(keyframes) == 2
assert keyframes[0].frame_index == 0
assert keyframes[1].frame_index == 5
def test_multimodal_window_alignment():
speech = [TranscriptSegment(start_sec=2.0, end_sec=12.0, text="Here we configure the PostgreSQL HNSW index.")]
visuals = [
VisualFrameCaption(
timestamp_sec=5.0,
visual_description="Terminal window showing psql prompt",
on_screen_ocr_text="CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);",
)
]
chunks = build_multimodal_video_chunks("vid_01", speech, visuals, window_sec=30.0)
assert len(chunks) == 1
assert chunks[0].formatted_time_range == "[00:00 - 00:30]"
assert "CREATE INDEX ON items USING hnsw" in chunks[0].combined_search_text
Storage, Indexing & Cost Considerations
- Cost Breakdown: Extracting ~150 scene-change keyframes from a 60-minute video (at 250 tokens per resized keyframe = ~37,500 vision tokens) plus Faster-Whisper (https://github.com/SYSTRAN/faster-whisper) audio transcription costs ~$0.02–$0.05 per hour of video on commercial mini VLMs or $0 using local Qwen2.5-VL via Ollama (https://github.com/ollama/ollama).
Useful Tools, Documentation and GitHub Repositories
| Resource | Purpose | Type | Link |
|---|---|---|---|
| PySceneDetect | Python/OpenCV video scene cut and boundary detection | Open-Source (BSD-3) | https://www.google.com/search?q=https://github.com/Breakthrough/PySceneDetect |
| Faster-Whisper | Audio track transcription with word timestamps | Open-Source (MIT) | https://github.com/SYSTRAN/faster-whisper |
| Qdrant | Vector search engine for multimodal timestamp payloads | Open-Source (Apache-2.0) | https://github.com/qdrant/qdrant |
Frequently Asked Questions
Why shouldn't I just transcribe the audio track and ignore the video frames?
In coding tutorials, architecture walkthroughs, product demos, and silent security footage, critical information (commands typed in a terminal, chart numbers on a slide, or error stack traces) is never read aloud word-for-word by the speaker. Combining visual frame OCR/captions with audio transcripts makes on-screen text searchable.
How much does scene-change detection reduce Vision LLM token costs compared to 1-FPS sampling?
A 60-minute presentation sampled at 1 FPS produces 3,600 frames. Scene-change detection (select_scene_keyframes_from_luma) typically yields 100–180 keyframes, cutting Vision LLM costs and ingestion time by 95%.
How do I handle questions that require reading tiny text inside a specific code slide?
Follow a two-stage resolution strategy: index low-cost thumbnail captions (768p) for all keyframes during ingestion; when a user asks a question and top-3 timestamp windows are retrieved from Qdrant, load the original full-resolution 1080p/4K keyframe PNGs for just those 3 timestamps into the final VLM answer prompt.
How do I link the AI's answer directly to the exact moment in the video player?
Instruct the LLM to cite the formatted_time_range ([04:30 - 05:00]) and parse [MM:SS] in your frontend into videoElement.currentTime = minutes * 60 + seconds.
What if a speaker talks across the boundary of two 30-second windows (e.g., at 00:29)?
Use a 5-second sliding overlap (step = 25s, window = 30s) or align chunk boundaries directly to natural PySceneDetect scene cuts and Whisper sentence pauses.
Can I index silent CCTV or manufacturing inspection videos with this architecture?
Yes; when transcripts is empty, build_multimodal_video_chunks indexes purely on window_visuals accompanied by max_interval_sec=5.0 keyframe sampling.
Should I store raw base64 images inside Qdrant or PostgreSQL vector rows?
No—storing multi-megabyte base64 strings inside vector payloads bloats RAM and slows HNSW traversal. Store keyframe JPEGs in object storage (S3/MinIO) and store only the s3_key and extracted text caption in Qdrant.
Can this entire video assistant run locally on a single GPU?
Yes; PySceneDetect (https://www.google.com/search?q=https://github.com/Breakthrough/PySceneDetect) runs on CPU, while Faster-Whisper (https://github.com/SYSTRAN/faster-whisper) and Qwen2.5-VL-7B via Ollama (https://github.com/ollama/ollama) fit together on a single 16GB–24GB GPU.
Frequently asked questions
Why shouldn't I just transcribe the audio track and ignore the video frames?
In coding tutorials, architecture walkthroughs, product demos, and silent security footage, critical information (commands typed in a terminal, chart numbers on a slide, or error stack traces) is never read aloud word-for-word by the speaker. Combining visual frame OCR/captions with audio transcripts makes on-screen text searchable.
How do I link the AI's answer directly to the exact moment in the video player?
Instruct the LLM to cite the formatted_time_range ([04:30 - 05:00]) and parse [MM:SS] in your frontend into videoElement.currentTime = minutes * 60 + seconds.
What if a speaker talks across the boundary of two 30-second windows (e.g., at `00:29`)?
Use a 5-second sliding overlap (step = 25s, window = 30s) or align chunk boundaries directly to natural PySceneDetect scene cuts and Whisper sentence pauses.