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.
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
| Stage | Description |
|---|---|
Input | Any video clip (.mp4, .mov, .mkv) |
Phase 0 | Project scaffold, Neo4j via Docker, API key verification |
Phase 1 | Scene detection (ffmpeg), keyframe extraction (OpenCV), audio strip |
Phase 2 | Vision analysis (Gemini), transcription (ElevenLabs), context (Claude) |
Phase 3 | Scene Graph Builder → Neo4j Cypher MERGE transactions |
Phase 4 | FastAPI endpoints + React timeline UI |
Output | Queryable Scene Graph: Film → Scene → [Lighting, Speech, Mood, Theme …] |
Project Structure
Phase 0 — Environment Setup
Install Python packages:
pip install opencv-python ffmpeg-python google-generativeai anthropic \
elevenlabs neo4j fastapi uvicorn python-dotenv
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
# 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.
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')
Phase 1 — Extraction
Three modules handle ingestion. They run in sequence for each input clip.
| Module | Responsibility |
|---|---|
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]). |
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
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).
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]'}]
}
Phase 2 — Analysis
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())
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)
Phase 3 — Scene Graph
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);
| Node | Properties / Notes |
|---|---|
| Film | Root node. id = film title slug. |
| Scene | One node per detected scene cut. Stores tc_start, tc_end (SMPTE), timestamp_start, timestamp_end (seconds), fps, full_text. |
| Lighting | type (low-key, high-key, natural, neon …), quality (hard, soft, mixed). |
| Framing | shot_type (close, medium, wide …), angle (low, eye, high, dutch). |
| Mood | value string (e.g. 'tense isolation'). From Gemini vision prompt. |
| Speech | speaker (speaker_0 …), text. One node per speaker per scene. |
| AudioEvent | type ([laughter], [music], [silence] …). From ElevenLabs diarisation. |
| Theme | value string (e.g. 'urban alienation'). From Claude context analysis. |
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;
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).
uvicorn api.app:app --reload
http://localhost:8000/docs
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
Cost Model
Adjust the sliders to model your exact corpus size, scene duration, and model choices. Prices as of March 2026.
Cost Reduction Levers
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:7474and confirm the Bolt connection. -
03
Apply schema: paste
schema.cyphercontents 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.