Skip to content
VishalPatil
Projects / vmarker

vmarker

Caption every frame, transcribe every word, then find the moment or answer the question with a jump-to-frame citation.

Stack used

  • React
  • Python
  • FastAPI
  • PostgreSQL
  • RAG
  • Video

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.

SearchAsk
Question it answers"Show me the moment that looks like this""What happened, and why"
ChannelsVisual onlyCaptions, audio, on-screen text, plus a lexical channel
LLM callsZeroThree (planner, reranker, generator)
CostOne embedding and one SQL queryReal money per invocation
LimitsUnmeteredRolling 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.

System architecture

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

LayerChoiceWhy it earned its place
FrontendReact 19, Vite 7, Tailwind v4, TypeScriptSub-second cold start, @theme design tokens in CSS, React 19 transitions smooth burst WebSocket re-renders
BackendPython 3.13, FastAPI, RQ, psycopgThe pipeline is Python, so the service is Python. The worker made the choice for me
StorePostgres + pgvector via SupabaseOne operable store: vectors sit beside user_id and timestamp, so scoping is a WHERE clause, not a distributed join
Queue and fan-outRedis on UpstashRESP2-pinned, because Upstash rejects the RESP3 HELLO 3 handshake. Short-lived connections, one per operation
ObjectsCloudflare R210 GB free, $0 egress, S3-compatible via boto3. Egress is the line item that kills a media product
AuthSupabase AuthEmail magic-link plus Google OAuth, JWT verified via JWKS inside FastAPI
VisionAzure GPT-4o-mini visionGemini 2.5 Flash automatic fallback, no GPU and no ops, no daily-quota wall
ASRAzure WhisperChunked to ~30-second windows, gated by ENABLE_ASR, default on
OCREasyOCR, opt-inGated by ENABLE_OCR, default off, because it pulls in PyTorch and ~600 MB of weights
Embeddingsall-MiniLM-L6-v2, 384-dShared 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

Ingest pipeline, inside the RQ worker

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.

  1. 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.

  2. Extract frames

    ffprobe reads fps and duration, then uniform sampling at a default interval=1.0s, capped at max_frames=1000. Timestamps are computed as i/fps so they align to the real timeline rather than to frame ordinal.

  3. Transcribe

    Audio out to mp3, Azure Whisper, chunked into ~30-second windows (_TRANSCRIPT_WINDOW_SEC = 30.0), each embedded into video_transcript_chunks. Audio under 1 KB is treated as "no real audio", the cheap guard against a silent video generating a garbage transcript.

  4. Caption, check, OCR, embed

    A ThreadPoolExecutor fans process_frame across frames. Caption with Azure vision, three retries at 0.5 * 2^attempt backoff 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.

StageFailure modeWhat the user gets
QuotaThe +1 commits in _persist, only on successA failed call or a mid-stream disconnect never burns a slot
PlannerFalls back to permissive: fetch everythingDegraded cost, not degraded correctness
Channel routingDisabled channels floored at 0.25, not droppedA mis-planned channel can still surface a strong hit
RerankerReturns the input head unchangedWorse ordering, same answer
Generation_is_content_filter_error emits a notice eventThe retrieved frames, with an explanation, minus the prose
PersistLogged, never surfacedNothing. 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_stage is separate from processing_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 setting cancel_requested and the worker polling it between frames, so it stops cleanly rather than being killed mid-write.
  • trashed_cascade_video_ids is JSONB on projects. 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_quota uses lazy reset. An expired window is computed as used=0 at read time and only written when the row actually rotates. No cron, and cost proportional to active users rather than registered ones.
  • video_frame_ocr is a nullable-embedding sidecar to video_frames with ON DELETE CASCADE, because a frame can legitimately contain no text.
  • HNSW runs at defaults, m=16 and ef_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.