{"id":51111357,"url":"https://github.com/solomonneas/reelgrep","last_synced_at":"2026-06-24T18:00:50.778Z","repository":{"id":363746709,"uuid":"1247800363","full_name":"solomonneas/reelgrep","owner":"solomonneas","description":"Local video search and media analysis: ffprobe metadata, frame sampling, contact sheets, FTS5 subtitle search, clip export, and pluggable person/object detection.","archived":false,"fork":false,"pushed_at":"2026-06-10T06:25:55.000Z","size":251,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-10T08:13:12.570Z","etag":null,"topics":["cli","ffmpeg","fts5","python","sqlite","video-search","whisper"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/solomonneas.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-23T19:52:28.000Z","updated_at":"2026-06-05T15:49:18.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/solomonneas/reelgrep","commit_stats":null,"previous_names":["solomonneas/reelgrep"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/solomonneas/reelgrep","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solomonneas%2Freelgrep","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solomonneas%2Freelgrep/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solomonneas%2Freelgrep/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solomonneas%2Freelgrep/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/solomonneas","download_url":"https://codeload.github.com/solomonneas/reelgrep/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solomonneas%2Freelgrep/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34743465,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-24T02:00:07.484Z","response_time":106,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cli","ffmpeg","fts5","python","sqlite","video-search","whisper"],"created_at":"2026-06-24T18:00:49.941Z","updated_at":"2026-06-24T18:00:50.757Z","avatar_url":"https://github.com/solomonneas.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# reelgrep\n\n\u003e Local video search and media analysis. Find people, outfits, objects, scenes, spoken phrases, and useful clips inside your own video library.\n\nStatus: v0.4.0. Align command shipped: when a clean official transcript exists (PDF / TXT / MD) you can map it onto the Whisper timestamps to get accurate timing PLUS the institution's exact text. Web UI, transcription, and the TypeScript MCP wrapper (separate repo) all still work as before.\n\nreelgrep indexes video files on disk: it runs `ffprobe` for metadata, samples frames at a configurable interval, builds contact sheets, extracts embedded and sidecar subtitles into a SQLite FTS5 table you can grep, and exports clips, screenshots, and animated WebP loops with a JSON manifest sidecar for each output. Person and object detection is pluggable, with a face-embedding backend and an Ollama vision-LLM backend; both accept confirmed positive AND negative reference images so lookalikes do not slip through. Everything runs on your machine - frames, clips, manifests, and the index all stay on disk under your home directory by default, and nothing leaves the box without you configuring it to.\n\n## Why\n\n- Lectures and conference talks: jump to the moment the speaker discussed X.\n- Personal and family videos: find clips of a specific person across years of footage.\n- TV episodes and movies: build contact sheets, cut highlight reels, export GIFs.\n- Training and onboarding videos: extract slides, search transcripts, build searchable archives.\n- All processing is local. Frames, clips, manifests, and the index stay on disk under your home directory.\n\n## Install\n\n```bash\n# CLI only (no model deps; subtitle search + ingest + export work):\npipx install reelgrep\n\n# With local face recognition (insightface + onnxruntime, ~300MB on first model load):\npipx install \"reelgrep[face]\"\n\n# With Ollama vision-LLM backend (httpx; assumes ollama serve is reachable):\npipx install \"reelgrep[vision]\"\n\n# With local Whisper transcription (faster-whisper + ctranslate2, ~75MB-2.9GB depending on model):\npipx install \"reelgrep[whisper]\"\n\n# With the local browser UI (starlette + uvicorn):\npipx install \"reelgrep[web]\"\n\n# With prose-transcript alignment (pypdf + rapidfuzz, ~1MB):\npipx install \"reelgrep[align]\"\n\n# Everything:\npipx install \"reelgrep[face,vision,whisper,web,align]\"\n```\n\nSystem dependency: `ffmpeg` and `ffprobe` must be on PATH. On Ubuntu:\n\n```bash\nsudo apt install ffmpeg\n```\n\nOn macOS:\n\n```bash\nbrew install ffmpeg\n```\n\n## Quickstart\n\n### Ingest a video\n\n```bash\nreelgrep ingest ~/Videos/some-talk.mp4\n```\n\nOutput:\n\n```text\ningested: /home/you/Videos/some-talk.mp4\nhash:     blake2b:8f3a91c4e6b7d2a05f1c4e6b7d2a05f1c4e6b7d2a05f1c4e6b7d2a05f1c4e6b7\nduration: 00:42:18.500\nsubtitle tracks: 1 (cues: 482)\nframes sampled: 508\ndb:       /home/you/.local/share/reelgrep/index.sqlite\n```\n\nIngest probes the file, samples one frame every five seconds by default (`--every 5`), pulls any embedded subtitle streams plus matching `.srt`/`.vtt` sidecars, and writes everything into the local index. Re-running on the same file is a no-op unless you pass `--force`.\n\n### Search what was said\n\n```bash\nreelgrep search-subtitles ~/Videos/some-talk.mp4 \"kubernetes\"\n```\n\nOutput:\n\n```text\n00:04:12.300  so this is where kubernetes comes in\n00:11:45.880  kubernetes scheduling is fundamentally a bin-packing problem\n00:27:03.120  the kubernetes control plane has five core components\n3 matches\n```\n\nRequires either embedded subtitles, a sidecar `.srt`/`.vtt` next to the video, or Whisper-transcribed cues (see next section). The `[whisper]` extra adds local transcription so screen recordings, lectures, and other un-captioned videos become searchable.\n\n### Transcribe a video without subtitles\n\n```bash\nreelgrep transcribe ~/Videos/lecture.mp4 --model tiny\n```\n\nOutput:\n\n```text\ntranscribing lecture.mp4 with whisper:tiny...\ntranscribed: /home/you/Videos/lecture.mp4\nlanguage:    en\nmodel:       whisper:tiny\ncues:        165\nspan:        00:00:01.460 -\u003e 00:18:44.070\n```\n\nReal numbers from an 18-minute 720p lecture screen recording: `tiny` model finishes in ~26 seconds on CPU and produces searchable cues. Larger models (`small`, `medium`, `large-v3`, `large-v3-turbo`) trade speed for accuracy. After transcribing, `reelgrep search-subtitles` works against the new cues immediately.\n\nThe cues are stored alongside any embedded or sidecar subtitles with `source='whisper'`, so the index treats them uniformly. Re-running transcribe on the same video is a no-op unless you pass `--force`. Pass `--no-db` to print the cues as JSON to stdout instead of writing to the index.\n\nYou can also fold transcription into ingest itself: `reelgrep ingest ~/lecture.mp4 --transcribe` runs Whisper only when the normal embedded/sidecar pass finds nothing.\n\n### Align an official transcript onto Whisper timestamps\n\nIf the institution ships a clean prose transcript next to the video (Canvas / Kaltura courses, conference talk hosts that post the speaker's text afterwards), Whisper's transcription is the wrong source of truth - the official transcript is cleaner and uses correct terminology. `reelgrep align` maps the official text onto the Whisper-derived timestamps so you keep accurate timing AND the canonical wording.\n\n```bash\nreelgrep align ~/Videos/lecture.mp4 --transcript ~/Videos/lecture_transcript.pdf --out lecture.srt\n```\n\nOutput:\n\n```text\naligned:        /home/you/Videos/lecture.mp4\ntranscript:     /home/you/Videos/lecture_transcript.pdf\nlanguage:       en\ncues:           221 (matched 2631/2650 transcript words, coverage 99.3%)\navg similarity: 0.98\nsrt:            /home/you/lecture.srt\n```\n\nReal numbers from an 18-minute USF lecture aligned against the course's official PDF transcript: 221 cues, 99.3% coverage of transcript words, 0.98 average similarity. The aligned cues preserve official terminology (\"module one\" vs Whisper's \"module 1\"), proper punctuation, and capitalization that Whisper either drops or mis-spells.\n\nAccepts `.txt`, `.md`, `.pdf` transcripts. Auto-runs `whisper:tiny` if no cues exist for the video yet, so the typical flow is one-shot. Cues land in the `subtitles` table with `source='aligned'` so they coexist with `whisper`, `embedded`, and `sidecar` sources. The optional `--out file.srt` writes a standard SRT file you can hand to a video player.\n\nCues whose similarity to the transcript falls below `--min-similarity` (default 0.55) keep their original Whisper text rather than being fabricated - if the transcript doesn't actually match the audio for a stretch (Q\u0026A inserted, slide change, etc.), the engine refuses to invent alignment.\n\n### Browse the whole library in a local web UI\n\n```bash\nreelgrep serve\n```\n\nOpens `http://127.0.0.1:8765/` in your default browser. The UI surfaces every ingested video in a sidebar, every cue (embedded, sidecar, or Whisper) in a searchable Subtitles tab per video, every sampled frame in a paginated grid with a lightbox, every person-search result with thumbnails grouped by confidence, and every export artifact with its manifest sidecar link.\n\nThe headline feature is the search bar in the header: type a phrase once and the UI fans out FTS5 queries across every video in the index, then groups the hits by video. Clicking a hit jumps you straight into that video's Subtitles tab with the term highlighted. With 36 lectures transcribed via `whisper:small`, a single query against \"database\" returns the full hit list across the semester in under a second.\n\nThe server binds to loopback only by default (`--host 127.0.0.1`), reads exclusively from the local SQLite index, and serves frame and export files via an allow-list (paths must already be referenced in the index - it is not a general filesystem proxy). Pass `--no-open-browser` to skip the auto-launch, `--port` to change the port, and `--reload` for frontend development.\n\n### Find a specific person\n\n```bash\nreelgrep find-person ~/Videos/some-talk.mp4 \\\n  --label speaker_a \\\n  --positive ~/refs/speaker_a/headshot1.jpg \\\n  --positive ~/refs/speaker_a/headshot2.jpg \\\n  --negative ~/refs/false_positives/looks_similar_but_isnt.jpg \\\n  --out ./speaker_a_matches\n```\n\nOutput:\n\n```text\nlabel:     speaker_a\nbackend:   face_embed\nthreshold: 0.3\nmatches:   12 / 25 (showing top 12)\n\n   00:00:14.500  conf 0.71  face cosine 0.71 vs centroid; margin 0.18 over nearest negative\n   00:00:42.000  conf 0.68  face cosine 0.68 vs centroid; margin 0.15 over nearest negative\n   ...\nmanifest:  /home/you/speaker_a_matches/find-person.manifest.json\nexports:   /home/you/speaker_a_matches (12 files)\n```\n\n#### Why negatives matter\n\nFace matching from cast or speaker headshots alone is unreliable - lookalikes, twins, family members, and similar-looking people in similar settings will all score high. reelgrep treats matching as a precision-over-recall job: positive examples anchor the search, and negative examples (a known false positive caught in a prior run, a sibling's photo, a stock image of the same demographic) push lookalikes below the acceptance threshold. The default backend uses cosine distance to the positive centroid minus the nearest negative cosine; the more negatives you provide, the fewer false positives you get back.\n\n### Cut a sub-clip\n\n```bash\nreelgrep export-clip ~/Videos/some-talk.mp4 --start 0:10:00 --end 0:10:30 --out highlight.mp4\n```\n\nStream-copies by default (fast, no re-encode). Pass `--reencode` if the source codec or container is awkward for downstream tools. Writes `highlight.mp4` plus `highlight.mp4.manifest.json` next to it.\n\n### Build a contact sheet\n\n```bash\nreelgrep contact-sheet ~/Videos/some-talk.mp4 --out sheet.jpg --cols 6 --every 30\n```\n\nSamples a frame every 30 seconds, lays them out in a 6-column grid, writes `sheet.jpg` plus a manifest. Pass `--use-cached` to reuse frames already sampled during ingest instead of re-sampling.\n\n### Render a webp loop\n\n```bash\nreelgrep make-gif ~/Videos/some-talk.mp4 --start 0:10:00 --duration 5 --out highlight.webp\n```\n\nThe output is animated WebP, not GIF - smaller files, better quality, supported in modern browsers and chat clients. Defaults: 12 fps, 480px wide. Tune with `--fps` and `--width`.\n\n## Backends\n\nreelgrep separates \"where is the video file?\" from \"what do I want to do with it?\" via a small backend layer:\n\n- **local** (default): pass a file path, it is used directly.\n- **jellyfin**: resolve a Jellyfin item name or 32-hex `ItemId` to its local file path via the Jellyfin HTTP API, then pipe into other commands. Configured via `JELLYFIN_URL` and `JELLYFIN_API_KEY` (same names as the `jellyfin-mcp` project).\n\nExample:\n\n```bash\nexport JELLYFIN_URL=http://jellyfin.local:8096\nexport JELLYFIN_API_KEY=\u003ckey\u003e\nreelgrep jellyfin resolve \"Talk: Container Networking\" | xargs -I {} reelgrep ingest {}\n```\n\n## Person and visual search models\n\nTwo backends ship in v0.1.0, both pluggable, both opt-in via extras:\n\n- **face_embed** (default, `[face]` extra): insightface ArcFace 512-dim embeddings. Fast on CPU, deterministic, well-suited to face matching with the positive/negative anchor pattern. Default acceptance threshold `0.30` (cosine margin over nearest negative).\n- **ollama_vision** (`[vision]` extra): per-frame chat against a local Ollama vision model (default `qwen2-vl:7b`, configurable via `OLLAMA_VISION_MODEL`). Slower per frame, but handles \"find frames where the speaker is wearing a red jacket\" or \"find shots of the building exterior\" - kinds of queries that pure face embeddings cannot answer. Default acceptance threshold `0.65`.\n\nSwitch engines with `--backend ollama_vision` on the `find-person` command. Both engines accept the same `--positive` / `--negative` / `--threshold` / `--top-k` flags.\n\n## Using reelgrep as a library\n\nBeyond the CLI, reelgrep ships a small Python library surface for\ndownstream packages that want to drive ingest, search, and\ntranscription programmatically, or to plug in a custom content\nsource (S3, an internal HTTP archive, etc.) or a custom person\nmodel.\n\n```bash\npip install reelgrep\n```\n\nSee [docs/extending.md](docs/extending.md) for the consumer-facing\nguide: installation, quickstart, `BaseBackend` and `BasePersonModel`\nworked examples, the `tags` table extension surface, and the\ncurrent stability contract.\n\n## Storage and privacy\n\n- The index database lives at `~/.local/share/reelgrep/index.sqlite` by default. Override with `REELGREP_HOME` or `REELGREP_DB`.\n- Sampled frames cache to `~/.local/share/reelgrep/cache/frames/\u003chash\u003e/` and subtitles to `~/.local/share/reelgrep/cache/subtitles/\u003chash\u003e/`.\n- Every export (clip, gif, screenshot, contact sheet) writes a JSON manifest sidecar next to it with the parameters and source hash so outputs are reproducible.\n- No telemetry. No background network calls. The Ollama backend talks to the Ollama URL you configure (default `http://127.0.0.1:11434`). The Jellyfin adapter talks only to the URL you set. Everything else stays local.\n- You are responsible for confirming you have the rights to analyze and store frames, clips, and derived data from the videos you process.\n\n## Configuration reference\n\n| Variable | Default | Description |\n|---|---|---|\n| `REELGREP_HOME` | `~/.local/share/reelgrep` | Root directory for the index and cache. |\n| `REELGREP_DB` | `\u003chome\u003e/index.sqlite` | Override the SQLite index path independently of `REELGREP_HOME`. |\n| `REELGREP_CACHE` | `\u003chome\u003e/cache` | Override the frame / subtitle cache directory. |\n| `REELGREP_FFMPEG` | `ffmpeg` | Path or name of the `ffmpeg` binary to invoke. |\n| `REELGREP_FFPROBE` | `ffprobe` | Path or name of the `ffprobe` binary to invoke. |\n| `JELLYFIN_URL` | (unset) | Base URL of a Jellyfin server for the `jellyfin` backend. |\n| `JELLYFIN_API_KEY` | (unset) | API key for the Jellyfin server. |\n| `OLLAMA_URL` | `http://127.0.0.1:11434` | Base URL of the Ollama server for the `ollama_vision` backend. |\n| `OLLAMA_VISION_MODEL` | `qwen2-vl:7b` | Model id Ollama should serve for vision requests. |\n\n## Commands\n\n| Command | What it does |\n|---|---|\n| `reelgrep ingest \u003cvideo\u003e` | Probe, extract subtitles, sample frames, persist to the index. See [Ingest a video](#ingest-a-video). |\n| `reelgrep info \u003chash-or-name\u003e` | Print metadata + counts for an indexed video. |\n| `reelgrep ls` | List indexed videos (most-recent first). |\n| `reelgrep export-clip \u003cvideo\u003e --start --end --out` | Cut a sub-clip, stream-copy by default. See [Cut a sub-clip](#cut-a-sub-clip). |\n| `reelgrep make-gif \u003cvideo\u003e --start --duration --out` | Render an animated WebP loop. See [Render a webp loop](#render-a-webp-loop). |\n| `reelgrep contact-sheet \u003cvideo\u003e --out` | Build a grid of thumbnails. See [Build a contact sheet](#build-a-contact-sheet). |\n| `reelgrep search-subtitles \u003cvideo\u003e \u003cquery\u003e` | FTS5 search over indexed subtitle cues. See [Search what was said](#search-what-was-said). |\n| `reelgrep transcribe \u003cvideo\u003e --model` | Whisper-transcribe and index cues for an un-captioned video. See [Transcribe a video without subtitles](#transcribe-a-video-without-subtitles). |\n| `reelgrep align \u003cvideo\u003e --transcript \u003cfile\u003e` | Map a clean prose transcript onto Whisper timestamps. See [Align an official transcript onto Whisper timestamps](#align-an-official-transcript-onto-whisper-timestamps). |\n| `reelgrep find-person \u003cvideo\u003e --label --positive --out` | Locate frames containing a person. See [Find a specific person](#find-a-specific-person). |\n| `reelgrep serve [--port 8765]` | Open the local browser UI for the whole index. See [Browse the whole library](#browse-the-whole-library-in-a-local-web-ui). |\n| `reelgrep jellyfin resolve \u003cquery\u003e` | Resolve a Jellyfin item to its local file path for piping. |\n| `reelgrep --db PATH \u003csubcommand\u003e` | One-shot override for the index database path. |\n\n## Development\n\n```bash\ngit clone https://github.com/solomonneas/reelgrep\ncd reelgrep\npython3 -m venv .venv\n.venv/bin/pip install -e \".[dev,face,vision,whisper,web,align]\"\n.venv/bin/pytest\n.venv/bin/ruff check .\n```\n\nTests marked `integration` shell out to the real `ffmpeg`, `ffprobe`, and `insightface` stacks. To skip them in a quick local loop:\n\n```bash\n.venv/bin/pytest -m \"not integration\"\n```\n\n## Roadmap\n\nQueued for later releases:\n\n- Writing thumbnails and chapters back to Jellyfin.\n- Cross-video person clustering (\"find all distinct faces in this whole library\").\n- True wav2vec2 word-level alignment for cases where cue-level timing isn't tight enough.\n\nShipped in v0.4.0: prose-transcript alignment (`reelgrep align`) via the `[align]` extra, plus a public Python library API (`reelgrep.index.ingest_video`) for embedders. See [Align an official transcript onto Whisper timestamps](#align-an-official-transcript-onto-whisper-timestamps). The MCP wrapper for agentic use also shipped as its own repo at [solomonneas/reelgrep-mcp](https://github.com/solomonneas/reelgrep-mcp) (`npm install -g reelgrep-mcp`).\n\nShipped in v0.3.0: local browser UI (`reelgrep serve`) backed by a Starlette JSON API + vanilla HTML/CSS/JS frontend, with cross-library subtitle search as the headline feature. See [Browse the whole library in a local web UI](#browse-the-whole-library-in-a-local-web-ui).\n\nShipped in v0.2.0: local Whisper transcription via the `[whisper]` extra and the new `reelgrep transcribe` command. See [Transcribe a video without subtitles](#transcribe-a-video-without-subtitles).\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsolomonneas%2Freelgrep","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsolomonneas%2Freelgrep","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsolomonneas%2Freelgrep/lists"}