Multimodal Scene Understanding · Technical Build Guide

Movie AI Pipeline

The engine behind the Scene Graph. Gemini 2.5 Flash analyses every frame. ElevenLabs Scribe v2 diarises every voice. Claude Sonnet reads the film. Neo4j stores it all.

🎬 Gemini 2.5 Flash — Vision 🎙 ElevenLabs Scribe v2 — Speech 🧠 Claude Sonnet — Context 🕸 Neo4j 5 — Scene Graph
4
Phases
10
Modules
~€0.11
Per Scene
~€210
Per 100 Films
01

Overview

Movie AI is a pipeline that automatically analyses film scenes for visual composition, audio texture, speech, and narrative context, then populates a Neo4j Scene Graph for interactive exploration and cross-film querying.

Guiding principle: Don't train — orchestrate. Gemini 2.5 Flash handles multimodal visual and audio analysis. ElevenLabs Scribe v2 handles speaker-diarised transcription and audio event tagging. Claude Sonnet handles higher-order cinematic reasoning. The Scene Graph Builder normalises and deduplicates all outputs before writing to Neo4j.

Pipeline at a Glance

INPUT
Video Clip
.mp4 / .mov / .mkv
PHASE 0
Scaffold
Neo4j · Docker
PHASE 1
Extraction
ffmpeg · OpenCV
PHASE 2a
Vision
Gemini 2.5 Flash
PHASE 2b
Speech
ElevenLabs Scribe v2
PHASE 2c
Context
Claude Sonnet
PHASE 3
Graph Builder
Neo4j MERGE
PHASE 4
API + UI
FastAPI · React
StageDescription
InputAny video clip (.mp4, .mov, .mkv)
Phase 0Project scaffold, Neo4j via Docker, API key verification
Phase 1Scene detection (ffmpeg), keyframe extraction (OpenCV), audio strip
Phase 2Vision analysis (Gemini), transcription (ElevenLabs), context (Claude)
Phase 3Scene Graph Builder → Neo4j Cypher MERGE transactions
Phase 4FastAPI endpoints + React timeline UI
OutputQueryable Scene Graph: Film → Scene → [Lighting, Speech, Mood, Theme …]
02

Project Structure

movie_ai/ ├── .env # API keys and DB connection ├── docker-compose.yml # Neo4j container ├── requirements.txt # Python dependencies ├── pipeline.py # Main orchestrator ├── scene_detector.py # ffmpeg scene cut detection ├── extractor.py # Keyframe + audio extraction + SMPTE timecode ├── transcriber.py # ElevenLabs Scribe v2 ├── vision_analyzer.py # Gemini 2.5 Flash ├── context_analyzer.py # Claude Sonnet ├── graph_builder.py # Normalise → Neo4j MERGE ├── schema.cypher # Constraints + indexes (run once) └── api/ └── app.py # FastAPI: /analyze-scene, /query-graph
03

Phase 0 — Environment Setup

3.1 Dependencies

Install Python packages:

pip install opencv-python ffmpeg-python google-generativeai anthropic \
            elevenlabs neo4j fastapi uvicorn python-dotenv
3.2 Environment Variables (.env)
GEMINI_API_KEY=your_key_here
ANTHROPIC_API_KEY=your_key_here
ELEVENLABS_API_KEY=your_key_here
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=password
3.3 Neo4j via Docker Compose
# docker-compose.yml
services:
  neo4j:
    image: neo4j:5
    ports:
      - "7474:7474"   # Browser UI
      - "7687:7687"   # Bolt protocol
    environment:
      NEO4J_AUTH: neo4j/password
    volumes:
      - neo4j_data:/data
volumes:
  neo4j_data:

Start the container: docker compose up -d — then open http://localhost:7474 to access Neo4j Browser.

3.4 Verify API Connections

Quick smoke test for all three APIs before running the full pipeline:

import google.generativeai as genai, anthropic, os
from elevenlabs.client import ElevenLabs
from dotenv import load_dotenv
load_dotenv()

genai.configure(api_key=os.getenv('GEMINI_API_KEY'))
model = genai.GenerativeModel('gemini-2.5-flash')
print('Gemini:', model.generate_content('ping').text[:20])

client = anthropic.Anthropic(api_key=os.getenv('ANTHROPIC_API_KEY'))
msg = client.messages.create(model='claude-sonnet-4-6', max_tokens=10,
    messages=[{'role':'user','content':'ping'}])
print('Claude:', msg.content[0].text)

el = ElevenLabs(api_key=os.getenv('ELEVENLABS_API_KEY'))
print('ElevenLabs: connected')
04

Phase 1 — Extraction

Three modules handle ingestion. They run in sequence for each input clip.

ModuleResponsibility
scene_detector.py Uses ffmpeg to detect shot boundaries by pixel-difference threshold. Returns list of timestamps in seconds.
extractor.py Extracts one representative keyframe per detected scene using OpenCV. Strips audio to mono 16 kHz WAV for ElevenLabs. Computes SMPTE timecode (HH:MM:SS:FF) for every scene.
transcriber.py Sends full-clip audio to ElevenLabs Scribe v2. Returns speaker-diarised transcript plus audio event tags ([laughter], [music], [silence]).
scene_detector.py
ffmpeg pixel-difference threshold detects hard cuts. Adjust threshold (0.3–0.5) for different genre editing rhythms.
import subprocess

def detect_scenes(video_path: str, threshold: float = 0.4) -> list[float]:
    """Returns list of cut timestamps in seconds."""
    result = subprocess.run([
        "ffmpeg", "-i", video_path,
        "-vf", f"select='gt(scene,{threshold})',showinfo",
        "-vsync", "vfr", "-f", "null", "-"
    ], capture_output=True, text=True)
    timestamps = []
    for line in result.stderr.splitlines():
        if "pts_time" in line:
            for part in line.split():
                if part.startswith("pts_time:"):
                    timestamps.append(float(part.split(':')[1]))
    return timestamps
extractor.py — Keyframes + SMPTE Timecode
SMPTE timecode (HH:MM:SS:FF) is the film industry standard. Computed from float seconds and the clip's native frame rate, or read directly from the video stream if embedded.
import cv2, subprocess
from pathlib import Path

def get_fps(video_path: str) -> float:
    cap = cv2.VideoCapture(video_path)
    fps = cap.get(cv2.CAP_PROP_FPS)
    cap.release()
    if abs(fps - 29.97)  < 0.01: return 29.97
    if abs(fps - 23.976) < 0.01: return 23.976
    return fps

def seconds_to_smpte(seconds: float, fps: float) -> str:
    """Convert float seconds to SMPTE HH:MM:SS:FF"""
    total_frames = int(round(seconds * fps))
    ff = total_frames % int(fps)
    ss = (total_frames // int(fps)) % 60
    mm = (total_frames // (int(fps) * 60)) % 60
    hh =  total_frames // (int(fps) * 3600)
    return f'{hh:02d}:{mm:02d}:{ss:02d}:{ff:02d}'

def extract_keyframes(video_path, scene_cuts, output_dir) -> dict:
    fps = get_fps(video_path)
    Path(output_dir).mkdir(parents=True, exist_ok=True)
    cap = cv2.VideoCapture(video_path)
    total_secs = cap.get(cv2.CAP_PROP_FRAME_COUNT) / fps
    cuts = [0.0] + scene_cuts + [total_secs]
    segments = [(cuts[i], cuts[i+1]) for i in range(len(cuts)-1)]
    result = {}
    for idx, (start, end) in enumerate(segments):
        mid = (start + end) / 2
        cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
        ret, frame = cap.read()
        if ret:
            path = f'{output_dir}/scene_{idx:04d}_{mid:.1f}s.jpg'
            cv2.imwrite(path, frame)
            result[idx] = {
                'start':      start,
                'end':        end,
                'tc_start':   seconds_to_smpte(start, fps),
                'tc_end':     seconds_to_smpte(end,   fps),
                'fps':        fps,
                'frame_path': path
            }
    cap.release()
    return result

def extract_audio(video_path: str, output_path: str) -> str:
    subprocess.run([
        "ffmpeg", "-i", video_path,
        "-vn", "-acodec", "pcm_s16le",
        "-ar", "16000", "-ac", "1",
        output_path, "-y"
    ], check=True)
    return output_path

Note on embedded SMPTE: If the source clip has embedded SMPTE timecode (common with camera originals and DCP masters), read it directly via ffprobe rather than computing it. Embedded timecode is authoritative and matches the editorial cut list (EDL).

transcriber.py — ElevenLabs Scribe v2
Speaker-diarised transcription with word-level timestamps and audio event tags. Returns structured dict with speakers, audio_events, and full_text.
from elevenlabs.client import ElevenLabs
import os
client = ElevenLabs(api_key=os.getenv('ELEVENLABS_API_KEY'))

def transcribe(audio_path: str) -> dict:
    """Returns speaker-diarised transcript + audio events."""
    with open(audio_path, 'rb') as f:
        response = client.speech_to_text.convert(
            file=f,
            model_id='scribe_v2',
            diarize=True,              # speaker_0, speaker_1 ...
            tag_audio_events=True,     # [laughter], [music] ...
            timestamps_granularity='word'
        )
    speakers, events = {}, []
    for word in response.words:
        if word.type == 'audio_event':
            events.append({'time': word.start, 'event': word.text})
        else:
            spk = getattr(word, 'speaker_id', 'speaker_0')
            speakers.setdefault(spk, []).append(
                {'word': word.text, 'start': word.start, 'end': word.end}
            )
    return {
        'full_text':    response.text,
        'speakers':     speakers,   # {'speaker_0': [{word, start, end}, ...]}
        'audio_events': events      # [{'time': 12.3, 'event': '[laughter]'}]
    }
05

Phase 2 — Analysis

vision_analyzer.py — Gemini 2.5 Flash
Sends each keyframe to Gemini 2.5 Flash with a strict JSON prompt. The model returns structured scene attributes in a single multimodal API call — lighting, framing, colour grading, objects, camera movement, and mood.
import google.generativeai as genai
import json, os, base64
genai.configure(api_key=os.getenv('GEMINI_API_KEY'))
model = genai.GenerativeModel('gemini-2.5-flash')

VISION_PROMPT = '''Analyse this film frame. Return ONLY valid JSON, no markdown:
{
  "lighting":       {"type": "low-key|high-key|natural|neon|...",
                     "quality": "hard|soft|mixed"},
  "framing":        {"shot_type": "extreme_close|close|medium|wide|extreme_wide",
                     "angle": "low|eye|high|dutch"},
  "color_grading":  {"dominant_palette": ["color1","color2"],
                     "tone": "warm|cold|desaturated|vibrant"},
  "objects":        ["list", "of", "visible", "objects"],
  "camera_movement":"static|pan|tilt|dolly|handheld|unknown",
  "lens_estimate":  "wide_angle|standard|telephoto|unknown",
  "mood":           "primary mood in 2-3 words"
}'''

def analyze_frame(frame_path: str) -> dict:
    with open(frame_path, 'rb') as f:
        img_data = base64.b64encode(f.read()).decode()
    response = model.generate_content([
        {'mime_type': 'image/jpeg', 'data': img_data},
        VISION_PROMPT
    ])
    text = response.text.strip()
    if text.startswith('```'):
        text = text.split('```')[1]
        if text.startswith('json'): text = text[4:]
    return json.loads(text.strip())
context_analyzer.py — Claude Sonnet
Sends Gemini vision output plus the ElevenLabs transcript to Claude Sonnet for deeper cinematic reasoning — symbolism, director influence, narrative function, and thematic tags.
import anthropic, json, os
client = anthropic.Anthropic(api_key=os.getenv('ANTHROPIC_API_KEY'))

def analyze_context(vision: dict, transcript: dict, film_title: str) -> dict:
    prompt = f"""You are a film studies expert. Given this scene from "{film_title}",
identify deeper cinematic context. Return ONLY valid JSON:
{{
  "symbolism":                ["list of symbolic elements if any"],
  "cinematographic_references":["director/film influences if detectable"],
  "narrative_function":        "establishing|conflict|climax|resolution|transition",
  "emotional_arc":             "description of emotional progression",
  "thematic_tags":             ["theme1", "theme2"]
}}
Vision: {json.dumps(vision)}
Dialogue: {transcript['full_text'][:500]}
Audio events: {transcript["audio_events"]}"""
    msg = client.messages.create(
        model='claude-sonnet-4-6',
        max_tokens=600,
        messages=[{'role': 'user', 'content': prompt}]
    )
    text = msg.content[0].text.strip()
    if text.startswith('```'):
        text = text.split('```')[1].lstrip('json').strip()
    return json.loads(text)
06

Phase 3 — Scene Graph

6.1 schema.cypher — Run once on a fresh Neo4j instance
CREATE CONSTRAINT film_id     IF NOT EXISTS FOR (f:Film)     REQUIRE f.id IS UNIQUE;
CREATE CONSTRAINT scene_id    IF NOT EXISTS FOR (s:Scene)    REQUIRE s.id IS UNIQUE;
CREATE INDEX      scene_tc    IF NOT EXISTS FOR (s:Scene)    ON (s.tc_start);
CREATE INDEX      scene_ts    IF NOT EXISTS FOR (s:Scene)    ON (s.timestamp_start);
CREATE INDEX      lighting_t  IF NOT EXISTS FOR (l:Lighting) ON (l.type);
CREATE INDEX      mood_val    IF NOT EXISTS FOR (m:Mood)     ON (m.value);
6.2 Graph Node Schema
NodeProperties / Notes
FilmRoot node. id = film title slug.
SceneOne node per detected scene cut. Stores tc_start, tc_end (SMPTE), timestamp_start, timestamp_end (seconds), fps, full_text.
Lightingtype (low-key, high-key, natural, neon …), quality (hard, soft, mixed).
Framingshot_type (close, medium, wide …), angle (low, eye, high, dutch).
Moodvalue string (e.g. 'tense isolation'). From Gemini vision prompt.
Speechspeaker (speaker_0 …), text. One node per speaker per scene.
AudioEventtype ([laughter], [music], [silence] …). From ElevenLabs diarisation.
Themevalue string (e.g. 'urban alienation'). From Claude context analysis.
6.3 Example Cypher Queries

Find all scenes with low-key lighting and a tense mood:

MATCH (f:Film)-[:HAS_SCENE]->(s:Scene)
WHERE EXISTS { MATCH (s)-[:HAS_LIGHTING]->(:Lighting {type: 'low-key'}) }
AND   EXISTS { MATCH (s)-[:HAS_MOOD]->(:Mood {value: 'tense isolation'}) }
RETURN f.id, s.tc_start, s.tc_end
ORDER BY f.id, s.timestamp_start;

Find all scenes where laughter is tagged:

MATCH (f:Film)-[:HAS_SCENE]->(s:Scene)-[:HAS_AUDIO_EVENT]->(ae:AudioEvent)
WHERE ae.type = '[laughter]'
RETURN f.id, s.tc_start, s.full_text
LIMIT 20;

Cross-film comparison — find scenes with identical lighting + mood across different films:

MATCH (f1:Film)-[:HAS_SCENE]->(s1:Scene)-[:HAS_LIGHTING]->(l:Lighting)
      <-[:HAS_LIGHTING]-(s2:Scene)<-[:HAS_SCENE]-(f2:Film)
WHERE f1.id <> f2.id
RETURN f1.id, s1.tc_start, f2.id, s2.tc_start, l.type
LIMIT 10;
07

Phase 4 — FastAPI

api/app.py exposes a /query GET endpoint accepting optional filter parameters: lighting, mood, audio_event, film. Builds a dynamic Cypher query and returns matching scenes (up to 20).

START SERVER
uvicorn api.app:app --reload
SWAGGER UI
http://localhost:8000/docs
NEO4J BROWSER
http://localhost:7474

The pipeline orchestrator (pipeline.py) is the single entry point. Pass a video path and film title — it runs all phases end to end, scene detection through Neo4j ingestion.

# Example invocation
python pipeline.py
# Input: drive_clip.mp4 | Film title: 'Drive'
# Output: fully-populated Neo4j Scene Graph
08

Cost Model

Adjust the sliders to model your exact corpus size, scene duration, and model choices. Prices as of March 2026.

Pipeline

Cost Calculator

2000
Total scenes
Scene duration 3 min
Scenes per film 20
Films to analyse 100
Vision model
Context analysis
Speech / STT
diarization · audio events
Per scene
 
Per film
 
Total corpus
 
Per-scene breakdown
⚠ Gemini 2.0 Flash is deprecated — shutdown scheduled June 1 2026. Budget on 2.5 Flash.

Cost Reduction Levers

🎞
Keyframe Sampling
Use scene-cut only to reduce Gemini video tokens by 60–70%. Toggle via the calculator above.
🎙
Audio-Only to ElevenLabs
Strip audio from the Gemini call. Send audio to ElevenLabs only — no duplication of processing cost.
🧠
Skip Context on POC
Set Context Analysis to "None" in the calculator — Gemini handles mood/symbolism instead of Claude.
Batch Mode
Use batch processing on Gemini and Claude for a 50% discount on non-urgent workloads.
09

First Run Checklist

Follow these steps in order to validate the full stack on a single test clip before scaling.

  • 01 Start Neo4j: docker compose up -d
  • 02 Open Neo4j Browser at http://localhost:7474 and confirm the Bolt connection.
  • 03 Apply schema: paste schema.cypher contents into the query box and run.
  • 04 Run API smoke test (Section 3.4) to confirm all three API keys respond correctly.
  • 05 Place a short test clip (2–5 min) in the project root as drive_clip.mp4.
  • 06 Run: python pipeline.py
  • 07 In Neo4j Browser, run the validation query below. If it returns rows, the stack is end-to-end validated.
MATCH (f:Film)-[:HAS_SCENE]->(s:Scene)-[:HAS_LIGHTING]->(l:Lighting)
RETURN f.id, s.tc_start, s.tc_end, l.type
ORDER BY s.timestamp_start;

Success criterion: The query returns rows with SMPTE timecodes, lighting types, and your film title as f.id. If it returns rows, the full pipeline — from video ingestion through AI analysis to graph storage — is working end to end.