Cinematlas
Search inside video: the frames and the spoken words, not just titles and tags.
Cinematlas turns a video (a YouTube link, a URL to a file, or an upload) into searchable scenes in MongoDB Atlas. It detects scene cuts, picks a keyframe for each scene, transcribes the dialogue, and embeds both with Voyage AI. You can then ask a question and get a deep link to the scene that answers it.
pip install "cinematlas[whisper]"
cinematlas setup
cinematlas ingest "www.b.com/keynote.mp4"
cinematlas search "when do they talk about pricing?"
Built on PySceneDetect, yt-dlp, faster-whisper, Voyage AI and MongoDB Atlas Vector Search, with or without Atlas autoEmbed. The engineering story is in blog.md.
How it works
URL / youtube / upload / stdin
│
▼
┌──────────────────┐ yt-dlp (H.264 + audio) or chunked upload → temp file
│ fetch + guard │ SSRF guard · http(s) only · size cap · credential redaction
└────────┬─────────┘
▼
┌──────────────────┐ ┌───────────────────────────┐
│ PySceneDetect │ │ ffmpeg → 16 kHz mono MP3 │
│ cuts + 30s cap │ │ faster-whisper + VAD │
└────────┬─────────┘ └─────────────┬─────────────┘
▼ │ overlap-aware
middle-frame keyframes │ scene assignment
▼ ▼
voyage-multimodal-3.5 transcript per scene
(image vectors) ├─ Atlas autoEmbed (voyage-4), or
│ └─ client-side voyage-4 vectors
▼ ▼
┌──────────────────────────────────────────────────────┐
│ MongoDB Atlas: one document per scene │
│ $vectorSearch: visual_embedding · transcript │
└──────────────────────────────────────────────────────┘
| Data | Voyage model | Why |
|---|---|---|
| Keyframes | voyage-multimodal-3.5 |
Images and text share one space, so a text query finds frames |
| Transcripts | voyage-4 family |
Better text retrieval. -lite, base and -large share one space, so you can index with one and query with another |
Install
ffmpeg must be on your PATH.
brew install ffmpeg # macOS (Ubuntu: sudo apt-get install -y ffmpeg)
pip install "cinematlas[whisper]" # recommended: + local speech-to-text (faster-whisper, no PyTorch)
pip install cinematlas # core only (use OPENAI_API_KEY for Whisper API transcription)
pip install "cinematlas[all]" # + S3 keyframe uploads + OpenAI Whisper API
export MONGODB_URI="mongodb+srv://<user>:<password>@cluster.mongodb.net/" # MDB_URI also accepted
export VOYAGE_API_KEY="pa-..."
export OPENAI_API_KEY="sk-..." # optional: Whisper API instead of local
export S3_BUCKET_NAME="my-cinematlas-keyframes" # optional: keyframe thumbnails
Quickstart
from cinematlas import Cinematlas
engine = Cinematlas() # reads MONGODB_URI / MDB_URI and VOYAGE_API_KEY
engine.ensure_indexes() # one time; idempotent; returns "autoembed" or "client"
# From a URL: YouTube, a direct file link, or anything yt-dlp supports
engine.ingest_video("https://www.youtube.com/watch?v=5NhYvbMdbBU")
engine.ingest_video("www.b.com/v.mp4") # scheme-less works; fetched over https
# From a file or an upload
engine.ingest_file("talk.mp4")
# Search what was said
for hit in engine.search_transcript("how loud is a sonic boom?", top_k=3):
print(f"{hit['score']:.3f} {hit['timestamp_start']}s {hit['deep_link']}\n {hit['transcript']}")
# Search what was shown (text -> keyframes)
engine.search_visual_vector("an aircraft on a runway", top_k=3)
Every search takes video_id= to scope results to one video.
Ingestion sources
Remote URLs
ingest_video() accepts YouTube links, direct file URLs (https://cdn.example.com/v.mp4, presigned
S3/GCS/Azure URLs) and anything else yt-dlp supports. It's designed so you can safely pass URLs
supplied by your users:
| Concern | Behaviour |
|---|---|
| Scheme-less input | www.b.com/v.mp4 becomes https://www.b.com/v.mp4. clip.mp4 is reported as a missing file, not treated as a host |
| SSRF | The host is resolved first, and private, loopback, link-local and reserved addresses are refused (for example 169.254.169.254). Opt in with allow_private_urls=True for trusted internal sources |
| Schemes | http and https only |
| Size | max_download_mb (default 2048) is enforced by yt-dlp |
| Credentials | Signatures and tokens (X-Amz-*, sig, token, user:pass@, …) are redacted before storage. The raw URL is used only for the download. A re-signed URL maps to the same video_id |
| Deep links | YouTube gets ?t=42s. Direct files get the W3C Media Fragment #t=42, which browsers seek to and which leaves signed query strings valid |
| Transient failures | Downloads retry with backoff |
Redirects followed by yt-dlp aren't re-checked. If you accept arbitrary URLs from the public internet, also route downloads through an egress proxy.
File uploads
ingest_file() accepts a path, bytes, a binary file object, a FastAPI UploadFile, or a
Flask/Werkzeug FileStorage.
# FastAPI
@app.post("/videos")
def upload(file: UploadFile):
return {"scenes": engine.ingest_file(file)} # sync def: FastAPI runs it in a threadpool
# Flask
@app.post("/videos")
def upload():
return {"scenes": engine.ingest_file(request.files["video"])}
- Uploads are streamed to disk in 1 MiB chunks, so memory stays flat for large files.
- Without a
video_id, the ID is the content hash (file_<sha256[:16]>). Uploading the same file again replaces it instead of duplicating it. - Empty or undecodable files are rejected with
IngestionError.
CLI
cinematlas setup # {"transcript_mode": "autoembed"}
cinematlas ingest "https://www.youtube.com/watch?v=5NhYvbMdbBU"
cinematlas ingest www.b.com/v.mp4 # remote file
cinematlas ingest ./talk.mp4 --video-id talk-01 # local file
curl -sL https://b.com/v.mp4 | cinematlas ingest - --filename v.mp4 # stdin upload
cinematlas search "how loud is a sonic boom" -k 3 # JSON lines
cinematlas search "an aircraft in the sky" --by visual
Options: --uri, --db, --collection, --transcript-mode, -v. Exit code 1 on errors, with a
message instead of a traceback.
With or without Atlas autoEmbed
Transcripts can be searched in two ways, through one call (search_transcript):
autoembed: Atlas Automated Embedding embedstranscriptand the query text itself withvoyage-4(Atlas feature, currently in Preview).client: Cinematlas stores avoyage-4transcript_embeddingand embeds queries itself. This works on any deployment with Vector Search, includingmongodb/mongodb-atlas-local.
ensure_indexes() tries autoEmbed first and falls back to client if the cluster rejects it. If a
transcript index already exists, it keeps using that mode and never switches modes on existing data.
To force a mode, pass transcript_mode="autoembed" or "client".
Reliability
- Replacing a video never loses it. A re-ingest inserts the new scenes under a fresh
ingest_idand only then deletes older versions. After a failure, the previous version stays searchable next to aFAILEDtombstone. - Vectors stay aligned with scenes. Scenes without a usable keyframe get
None, and every other vector stays on its own scene. Short API responses are treated as failures, not shifted. - Speech goes to the right scene. Timestamps come from voice-activity-trimmed Whisper output. A segment belongs to every scene it overlaps by ≥ 0.5s, and always to the scene it overlaps most, so normal timestamp drift doesn't copy sentences across cuts.
- Scenes have a maximum length. Footage without hard cuts is split into scenes of at most 30s
(
max_scene_seconds). - Things degrade instead of crashing. Voyage and download calls retry with backoff. A missing audio track means visual-only indexing, and a failed transcription means an empty transcript.
- Documents record provenance.
embedding_modelsstores which models produced the vectors, for future re-embedding migrations.
Configuration
| Parameter | Default | Notes |
|---|---|---|
voyage_model |
voyage-multimodal-3.5 |
Keyframe vectors |
text_model |
voyage-4 |
Transcripts (autoEmbed index model or client-side) |
transcript_mode |
auto |
auto | autoembed | client |
whisper_model |
small |
Local faster-whisper model. We measured base mishearing ordinary words |
max_scene_seconds |
30 |
None disables the scene-length cap |
allow_private_urls |
False |
SSRF guard |
max_download_mb |
2048 |
Download size cap |
db_name / collection_name |
cinematlas_enterprise / multimodal_scenes |
Every external client can be passed in (mongo_client=, voyage_client=, s3_client=,
openai_client=), which is how the test suite runs offline.
Development & testing
uv sync
uv run pytest -m "not integration and not media" # unit: offline, ~8s
uv run pytest -m media # real ffmpeg / yt-dlp / Whisper on the fixture, offline
uv run pytest -m integration # live Atlas (autoEmbed) + Docker Atlas Local (client)
uv run pytest # everything
| Tier | What's real | What it proves |
|---|---|---|
| unit | OpenCV, PySceneDetect, ffmpeg on generated video. Voyage and Mongo are fakes that record calls and encode identity | Alignment, retries, fallback, uploads, URL safety, gapless replace, CLI |
| media | ffmpeg, faster-whisper, and our download path over loopback HTTP, on a committed real-speech fixture | Known cuts are found, transcripts are verbatim, each topic lands in its own scene, timestamps skip silence |
| integration | Live Atlas + Voyage and mongodb/mongodb-atlas-local in Docker |
URL and upload ingest end to end, and questions return the right scene, with and without autoEmbed |
No test contacts YouTube. End-to-end runs use
tests/fixtures/x59_quiet_crew.mp4 (847 KiB, NASA, public
domain): three sentence-aligned topics joined by hard cuts, which gives the tests ground truth for
scene alignment. URL ingestion is served from a loopback HTTP server. The fixture is checked by
SHA-256 and rebuilt with uv run python tests/fixtures/build_fixture.py.
Integration env (.env): MDB_URI or MONGODB_URI, VOYAGE_API_KEY, and optionally VOYAGE_MODEL.
The Atlas Local container starts and stops by itself on port 27028; set ATLAS_LOCAL_URI to reuse
one. Tests write to cinematlas_ci.scenes under a unique video_id and clean up after themselves.
Releasing
# bump version in pyproject.toml, then:
rm -rf dist && uv build && uv publish # uses UV_PUBLISH_TOKEN
Roadmap
- Async / queued ingestion (Celery, Temporal)
- OCR of on-screen text into scene documents
- Word-level timestamps for sentences that straddle a cut
AdaptiveDetectoroption for dissolve-heavy footage
License
MIT. Test fixture: NASA media, public domain (see build_fixture.py).
Release files for cinematlas 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| cinematlas-0.1.0.tar.gz | 905.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cinematlas-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 931.8 kB
Release files / cinematlas-0.1.0.tar.gz
| Download URL | cinematlas-0.1.0.tar.gz |
|---|---|
| Size | 905.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
de2c1e2516c077bcc343e9900e0b60d0c0b14e1e6b5a712fa9542d377a02365c
|
|
BLAKE2b-256 checksum How to use checksums |
d1dd137fe0b51e499de46213e8d49a523b6eedb38c6e7fe799ee38c02599d317
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / cinematlas-0.1.0-py3-none-any.whl
| Download URL | cinematlas-0.1.0-py3-none-any.whl |
|---|---|
| Size | 26.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
95120696e8da1a8f4d843ba2dfb25c0f931ffc168abd54ba58f77c9e36a1cf02
|
|
BLAKE2b-256 checksum How to use checksums |
9367fdda97a98ac7dacba89b16b121e102d81f9eb5a7b55cd4311a7f1267da6d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|