Why I built it
Video is the worst-indexed media type most people own. Lectures, meetings, gameplay, walkthroughs, family footage: the retrieval story is usually scrubbing a timeline. Full-text search solved this for documents decades ago. Video never got the equivalent, because the content is not text until you spend compute to make it text.
vmarker spends that compute once, at ingest, then treats the video like a document collection. Every frame becomes a caption. Every stretch of audio becomes a transcript chunk. Optionally, every frame's on-screen text becomes another string. Once all of that is embedded into one store, you get semantic search and grounded question answering over footage that previously had zero index.
Drop a video in. Ask it a question. Get an answer with citations that jump the player to the exact frame.
Research
I'm also studying how each channel helps different query types.
Search and Ask are deliberately different verbs
The two user-facing operations stay apart on purpose. They cost wildly different amounts and answer different questions.
| Search | Ask | |
|---|---|---|
| Question it answers | "Show me the moment that looks like this" | "What happened, and why" |
| Channels | Visual only | Captions, audio, on-screen text, plus a lexical channel |
| LLM calls | Zero | Three (planner, reranker, generator) |
| Cost | One embedding and one SQL query | Real money per invocation |
| Limits | Unmetered | Rolling 24-hour quota |
If I had merged them behind one endpoint with flags, the cheap operation would have inherited the expensive one's latency and cost model. Then I would have had to rate-limit browsing. Keeping the cheap operation cheap is the point.
Architecture
Three deployable units. The split is driven entirely by how long ingest takes.
Client
Auth-guarded /app routes: dashboard, upload, ask, history, v/:videoId, p/:projectId.
Stage chips plus captioned frames streaming in. React 19 transitions smooth the burst re-renders.
One-directional after the request, so SSE suffices - ordinary HTTP, free reconnection.
Compute (Azure Container Apps, one image)
FastAPI serving HTTP, WebSocket, and SSE. Stateless - everything durable lives elsewhere.
SimpleWorker does not fork, because the model SDKs misbehave across fork(). Scale by adding containers.
Outside api/ on purpose, so importing it does not trigger api/__init__ with routers and DB connections.
Data and external
Three channel tables all carry embedding VECTOR(384) from one shared encoder.
Job queue plus progress pubsub. RESP2 because Upstash rejects the RESP3 HELLO 3 handshake.
$0 egress, S3-compatible. Local tier writes to backend/.data/ on the same code path.
One GPT-4o-mini deployment does four jobs: vision, planner, reranker, generator.
- Client surface
- Compute
- Storage
- Third party
Three deployable units. The API and the worker are the same Docker image with different launch commands.
Frontend
Vite + React 19 SPA on Vercel. Public routes for landing and auth,
auth-guarded routes under /app for dashboard, upload, ask, history, video,
and project views.
Backend API
FastAPI on Azure Container Apps as vmarker-api, running
uvicorn backend.main:app. Serves HTTP, WebSocket, and SSE from one process.
Worker
The same Docker image, launched as
python -m backend.video.video_worker. An RQ SimpleWorker consuming the
video-jobs queue, running ffmpeg, Whisper, captioning, and embedding. One
build, one dependency set, no chance of drifting to different library versions
than the API.
ffmpeg, Whisper, and a vision call per frame take minutes and block. Splitting the worker out buys three things: the API stays responsive no matter how deep the queue gets, processing scales horizontally by adding worker containers with nothing about the API changing, and the job is durable because it lives in Redis, so an API restart does not lose queued work.
I chose RQ over Celery and Arq for the smallest dependency surface. No broker
beyond the Redis I already run for pubsub. Its built-in scheduling gives me
q.enqueue_in, which implements the 30-second soft-delete purge with no cron
and no extra process. SimpleWorker specifically, because it does not fork and
several of the model SDKs in the pipeline misbehave across fork(). The cost is
one job at a time per worker and a crash taking the worker down, which is fine
when the unit of scale is a container.
- React
- Python
- FastAPI
- PostgreSQL
- RAG
- Video
The tech stack, and why
| Layer | Choice | Why it earned its place |
|---|---|---|
| Frontend | React 19, Vite 7, Tailwind v4, TypeScript | Sub-second cold start, @theme design tokens in CSS, React 19 transitions smooth burst WebSocket re-renders |
| Backend | Python 3.13, FastAPI, RQ, psycopg | The pipeline is Python, so the service is Python. The worker made the choice for me |
| Store | Postgres + pgvector via Supabase | One operable store: vectors sit beside user_id and timestamp, so scoping is a WHERE clause, not a distributed join |
| Queue and fan-out | Redis on Upstash | RESP2-pinned, because Upstash rejects the RESP3 HELLO 3 handshake. Short-lived connections, one per operation |
| Objects | Cloudflare R2 | 10 GB free, $0 egress, S3-compatible via boto3. Egress is the line item that kills a media product |
| Auth | Supabase Auth | Email magic-link plus Google OAuth, JWT verified via JWKS inside FastAPI |
| Vision | Azure GPT-4o-mini vision | Gemini 2.5 Flash automatic fallback, no GPU and no ops, no daily-quota wall |
| ASR | Azure Whisper | Chunked to ~30-second windows, gated by ENABLE_ASR, default on |
| OCR | EasyOCR, opt-in | Gated by ENABLE_OCR, default off, because it pulls in PyTorch and ~600 MB of weights |
| Embeddings | all-MiniLM-L6-v2, 384-d | Shared by all three channels, which is the only reason cross-channel fusion means anything |
One detail worth noticing: GPT-4o-mini does four jobs here (vision captioning, query planning, reranking, and answer generation). One deployment, four call sites, four prompts. That keeps the vendor surface small and the cost model easy to reason about. ChromaDB survives in the dependency list purely as the source of that default sentence-transformer. I dropped it as the vector store and kept it as the encoder.
The ingest pipeline
Enqueue (synchronous, returns immediately)
The whole synchronous path: write the object, insert the videos row, enqueue the job.
last_heartbeat_at lets the UI tell a stalled worker from a slow one and offer Retry.
The job lives in Redis, so an API restart does not lose queued work.
Worker: download, extract, transcribe
MOV and AVI will not reliably play in a browser video element, and the UI must seek to citation timestamps.
ffprobe reads fps and duration. Timestamps are i/fps so they align to the real timeline.
Audio under 1 KB is treated as no real audio - the cheap guard against silent videos.
Worker: per-frame fan-out (ThreadPoolExecutor)
Azure vision primary, automatic Gemini 2.5 Flash fallback, placeholder caption on terminal failure.
One HIGH-severity hit raises NsfwViolationError, wipes the video, and is never retried elsewhere.
Off by default because it drags in PyTorch and roughly 600 MB of weights.
_safe_add_frame falls back to a constant embedding so the row count always equals the frame count.
Stage order is a behavior-preserving invariant. Every frame ends up with a row, even when captioning fails.
The entry point is process_video_frames in video/frame_processing.py,
roughly a 450-line orchestrator over the video/pipeline/ package. It builds a
frozen PipelineContext once (ids, paths, interval and fps, max frames,
language, database handles) and passes it down. The leaf stages are pure
functions, so the orchestrator holds all the mutable state and everything below
it is testable in isolation.
Download and transcode
Pull the source from R2 to a temp path, then normalize to a web-playable MP4. Uploaded MOV and AVI files will not reliably play in a browser
<video>element, and the UI has to seek to a citation timestamp.Extract frames
ffprobereads fps and duration, then uniform sampling at a defaultinterval=1.0s, capped atmax_frames=1000. Timestamps are computed asi/fpsso they align to the real timeline rather than to frame ordinal.Transcribe
Audio out to mp3, Azure Whisper, chunked into ~30-second windows (
_TRANSCRIPT_WINDOW_SEC = 30.0), each embedded intovideo_transcript_chunks. Audio under 1 KB is treated as "no real audio", the cheap guard against a silent video generating a garbage transcript.Caption, check, OCR, embed
A
ThreadPoolExecutorfansprocess_frameacross frames. Caption with Azure vision, three retries at0.5 * 2^attemptbackoff with automatic Gemini fallback. Read the vendor's own safety metadata. Optionally OCR. Embed and persist.
Threaded, not async. That was a change from the original plan. The workload is blocking vendor SDK calls and ffmpeg subprocesses, and the vision and Whisper SDKs had no proper async client, so I would have been wrapping sync calls in executors anyway: async's complexity with threads' performance.
The 1:1 guarantee matters. On terminal caption failure the pipeline writes a
placeholder caption, and _safe_add_frame falls back to a constant embedding
if embedding fails outright. Either way the row count always equals the frame
count, because a lot of downstream logic (progress percentages, frame
navigation, citation indexes) assumes frame i has a row.
Safety rejection is aggressive, knowingly
pipeline/safety.py reads the vision vendor's safety metadata, which arrives
with the caption, so the check costs no extra vendor call. A single
HIGH-severity hit raises NsfwViolationError, which wipes the whole video and
emits nsfw_reject. That error is never retried on the other provider, because
the content did not change and asking a second vendor is just shopping for a
permissive answer. For a public free tier, a false positive costs one user an
upload while a false negative costs me hosting content I should not host. I made
the strict call deliberately.
Progress flows worker to Redis pubsub to API to WebSocket to browser at
WS /api/ws/progress/{video_id}. The pubsub hop exists because the worker and
the API are separate containers. The worker has no idea which API container, if
any, is holding that user's socket. Alongside it, a heartbeat thread updates
last_heartbeat_at on the video row, which solves a different problem: pubsub
tells you what is happening, the heartbeat tells you whether anything is
happening at all, so the UI can distinguish a stalled worker from a slow one and
offer Retry.
How Ask finds the right moment
Naive retrieval merges all channels and sorts by raw cosine. That quietly favours whichever channel produces bigger numbers, independent of relevance. Captions, ASR windows, and OCR strings have different lengths and spreads, so a 0.62 from OCR and a 0.62 from ASR do not mean the same thing.
The product path calibrates within each channel first, adds a lexical overlap
score for proper nouns and short identifiers the sentence encoder tends to
squash, then expands strong hits by a few seconds of temporal neighbours.
Fusion method switches at runtime through VMARKER_FUSION_METHOD
(calibrated, rrf, or raw), so a bad fusion incident rolls back with an
env change and a restart, not a deploy.
plan_to_weights maps planner flags to channel weights: enabled gets 1.0,
disabled gets _CHANNEL_FLOOR = 0.25. Soft down-weight, not a hard drop,
because the planner is an LLM and will sometimes be wrong. A mis-planned channel
can still surface a strong hit and climb back into the candidate set.
Graceful degradation in the Ask pipeline
Five stages (quota pre-flight, plan, hybrid retrieve, LLM rerank, stream) with a best-effort persist at the end. The interesting half is what happens when each one fails.
| Stage | Failure mode | What the user gets |
|---|---|---|
| Quota | The +1 commits in _persist, only on success | A failed call or a mid-stream disconnect never burns a slot |
| Planner | Falls back to permissive: fetch everything | Degraded cost, not degraded correctness |
| Channel routing | Disabled channels floored at 0.25, not dropped | A mis-planned channel can still surface a strong hit |
| Reranker | Returns the input head unchanged | Worse ordering, same answer |
| Generation | _is_content_filter_error emits a notice event | The retrieved frames, with an explanation, minus the prose |
| Persist | Logged, never surfaced | Nothing. The answer was already on screen |
The content-filter case was a real decision, not an afterthought. Azure's filter false-positives regularly on benign footage. The retrieval was correct and the vendor refused only the synthesis, so throwing away the retrieval too would punish the user for someone else's false positive.
Citations arrive first. The stream emits a citations SSE event before a
single token of text, then delta chunks at temperature 0.2 and max 800
tokens, then done. Perceptually, the gap before the first token is where the
product feels slow, and retrieval and reranking are already finished by then, so
the UI can render citation chips immediately. Architecturally, it fixes the
citation set before generation instead of parsing it out of model output
afterwards. The inline [N] markers index into a list the server already
committed to, so a citation can never point at something that was not retrieved.
Schema decisions worth defending
processing_stageis separate fromprocessing_state. The fine-grained phase (extracting,captioning,cancel_requested, and the rest) drives a specific UI chip without overloading one column with two meanings. Cancel works by the UI settingcancel_requestedand the worker polling it between frames, so it stops cleanly rather than being killed mid-write.trashed_cascade_video_idsis JSONB onprojects. When a project is trashed, the cascaded video IDs are recorded, so undo restores exactly the set that was removed rather than everything currently in the project.ask_quotauses lazy reset. An expired window is computed asused=0at read time and only written when the row actually rotates. No cron, and cost proportional to active users rather than registered ones.video_frame_ocris a nullable-embedding sidecar tovideo_frameswithON DELETE CASCADE, because a frame can legitimately contain no text.- HNSW runs at defaults,
m=16andef_construction=64, sufficient up to a few million rows per user. The revisit trigger is tens of millions of frames per user, at which point I shard or move the hot vectors to Qdrant.
Design rationale
Why put vectors in Postgres instead of a dedicated vector database?
Because I get one operable store instead of two, and the joins are real. Every
vector row sits next to user_id, video_id, and timestamp in the same
table, so scoping a search to one user's videos inside one project is a single
SQL statement, transactionally consistent with the metadata. With a separate
vector store I would filter in two places and every soft delete would become a
distributed consistency problem. Trash a project in Postgres and the vector
store has orphaned rows until some reconciliation job catches up. pgvector also
came free with the Supabase project I was already running for auth.
Two extra LLM calls per ask, one to plan and one to rerank. Is that not expensive?
Yes, and it is the clearest cost-versus-quality tradeoff in the system: three round trips where a naive RAG pays one, with the first two serial and sitting in front of time-to-first-token. The path out, in order: cache plans, since similar queries want the same routing; replace the LLM reranker with a cross-encoder, which is faster, cheaper, and deterministic. Until then, I bought quality with latency and I know the price.
How do you control cost for users, and what is wrong with how you do it?
A 50 MB upload cap, a 1000-frame cap per video, and a rolling 24-hour ask quota defaulting to 10. What is wrong is that a daily ask count is blunt: it treats a one-frame lookup and a full-library question over a thousand frames as the same unit of cost, which they are not by an order of magnitude. Pricing by channel-tokens consumed would be fairer. I have not built that yet.
What I would flag before you find it
Documentation drift on keyframe extraction
The README and design docs say scene-change-based extraction, and there is a
FRAME_SCENE_THRESHOLD env knob in config to prove I intended it. The shipped
extract.py does uniform sampling at 1 fps. Uniform is defensible on its
merits (deterministic, timestamps are trivially i/fps, nothing to tune per
content type), but scene-change detection would give better coverage per frame
captioned, which matters when the caption is the expensive part. The lesson is
narrower than "keep docs updated": a config knob with no code reading it is a
lie with a long half-life, and a test asserting the knob changes behavior would
have caught it the day it diverged.
Two more in the same spirit. The shipped lexical channel is substring overlap, not BM25. Same intent, less sophistication. And the R2 bucket is public, so object keys are guessable; namespacing by user is obscurity, not authorization. Acceptable for a free tier where users upload things they are willing to have a public URL for, and unacceptable the moment private projects ship, at which point the fix is signed URLs.