DEV Community

Cover image for I built a video detective that runs on a 2B model and never phones home
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on AI-assisted

I built a video detective that runs on a 2B model and never phones home

Liquid AI shipped six open-weight models in the LFM3 family. Every one of them is derived from the same LFM3-1.7B base: a vision model, an omni model, an audio model, a text model, and their quantized variants. The launch coverage was benchmarks, which is the least interesting part of any model release.

So I ignored the benchmarks and asked the only question that matters: what can I build with this that I could not build last month?

The answer I landed on is Clip Detective. Drop in a video, ask a question in plain English, get an answer with timestamps that you can click. It runs entirely on my laptop. No API key, no upload, no per-second billing.

Billing

Q: What error is on screen, and at what second does it appear?

A: The error shown on screen is "ERROR 403 Forbidden from billing API" and it
   appears at t=04.50s [00:04]

   12 frames · 24.9s · 33 tokens · 0 cloud calls
Enter fullscreen mode Exit fullscreen mode

That is a 1.9B parameter model on an M2 Mac mini with 8GB of RAM. The whole stack uses 1.2GB resident and 1.2GB of model weights on disk.

Here is how it works, what broke, and the numbers.


Why video Q&A is the right demo for a 2B vision model

A 2B model is not going to beat a frontier model at describing a photograph. If your demo is "look at this image and tell me what it is", you are competing on a benchmark you will
lose.

Video is different, because video turns a static model into a temporal one, and
temporal answers are not something people expect from a small local model. "What error
appeared, and at what second" is a question with a checkable answer. So is "which frame
shows the button change color". Both are real work, both are worth money, and both are
gated behind cloud APIs today.

There are three reasons video is the right target:

  1. The value is verifiable. A timestamp is either right or wrong. You can check it by scrubbing. No vibes.
  2. The constraint is real. Nobody expects a 2B model to watch 30 minutes of footage. Expectations are already calibrated, so a good sampling layer over delivers.
  3. It is offline by nature. Video files are large and often private. "It never leaves your machine" is not a marketing line here, it is the reason the tool is usable for screen recordings of internal systems.

The core insight: the model does not see video

LFM3-VL-2B takes images. llama.cpp added LFM3 text and vision support in
PR #31252. Video is not in the
multimodal path.

So the interesting engineering is not in the model call. It is in deciding which frames
the model gets to see, and how those frames are labeled so the answer comes back in the
video's own timeline.

video ──ffmpeg scene detect──▶ ≤12 frames @ real timestamps
                                     │
                          labeled "Frame at 00:07"
                                     ▼
                        LFM3-VL-2B (Q4_K_M + Q8 mmproj)
                                     ▼
                    answer with mm:ss citations → click to seek
Enter fullscreen mode Exit fullscreen mode

Three processes, all on localhost:

┌────────────────────────────────────────────────────────────────────────────┐
│  browser :8787                                                             │
│  ┌──────────────┐   upload raw bytes    ┌───────────────────────────────┐  │
│  │ drop zone    │ ────────────────────▶ │  server.py                    │  │
│  │ frame strip  │                       │  ThreadingHTTPServer          │  │
│  │ ask box      │ ◀──────────────────── │  no framework, stdlib only    │  │
│  └──────────────┘   answer + timestamps └───────────┬───────────────────┘  │
└─────────────────────────────────────────────────────┼──────────────────────┘
                                                      │ in-process call
                                          ┌───────────▼───────────────────┐
                                          │  sampler.py                   │
                                          │  ffmpeg scene detect          │
                                          │  uniform fallback, dedupe     │
                                          │  ≤12 frames @ 896px           │
                                          └───────────┬───────────────────┘
                                                      │ data/<id>/*.jpg
                                                      │ base64 in JSON
                                          ┌───────────▼───────────────────┐
                                          │  llama-server :9911           │
                                          │  LFM3-VL-2B Q4_K_M            │
                                          │  + mmproj Q8_0, Metal GPU     │
                                          │  OpenAI-compatible /v1        │
                                          └───────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Sampling, which is where the quality lives

My first version picked frames at even intervals. It was fine for anything with constant
motion and useless for screen recordings, where the interesting thing is a single state
change in the middle of 40 static seconds.

Scene detection solves that, because ffmpeg already has it and it costs one pass:

def scene_frames(path: str, outdir: str, threshold: float):
    """Extract scene-change frames, return [(timestamp, filename)]."""
    cmd = [FFMPEG, "-v", "info", "-i", path, "-an",
           "-vf", f"select='gt(scene,{threshold})',showinfo,scale={FRAME_WIDTH}:-2",
           "-vsync", "vfr", "-q:v", "3", os.path.join(outdir, "scene_%04d.jpg")]
    proc = subprocess.run(cmd, capture_output=True, text=True)
    stamps = [float(m) for m in re.findall(r"pts_time:([0-9.]+)", proc.stderr)]
    files = sorted(f for f in os.listdir(outdir) if f.startswith("scene_"))
    return list(zip(stamps, files))
Enter fullscreen mode Exit fullscreen mode

showinfo prints each selected frame's presentation timestamp to stderr. Parse it with a
regex and you get exact frame times, for free, with no second pass and no OpenCV.

The threshold needs a fallback, though, and this bit me. A talking-head clip has almost no
scene changes. At threshold 0.30 I got two frames and a model that had effectively seen
nothing. So the sampler walks the threshold down, then fills the timeline uniformly if it
still has too few:

for thresh in (0.30, 0.18, 0.10):
    frames = scene_frames(path, outdir, thresh)
    if len(frames) >= 4:
        break

if len(frames) < 4:
    frames += uniform_frames(path, outdir, dur, MAX_FRAMES, 0)
Enter fullscreen mode Exit fullscreen mode

Then a de-dupe pass, because my first UI showed the same second twice and it looked like a
bug even to me:

def pick(frames, max_n):
    """Keep frames spread across the timeline, always keeping first and last.
    Dedupes by whole-second so the strip never shows the same moment twice."""
    seen_sec, uniq = set(), []
    for t, f in sorted(frames, key=lambda x: x[0]):
        if int(t) in seen_sec:
            continue
        seen_sec.add(int(t))
        uniq.append((t, f))
    if len(uniq) <= max_n:
        return uniq
    step = len(uniq) / max_n
    return [uniq[int(i * step)] for i in range(max_n)]
Enter fullscreen mode Exit fullscreen mode

Resolution is not a detail, it is the feature

12 frames at 448px was my starting point, because smaller images mean fewer prompt tokens
and a faster answer. Then I tested it on a real screen recording of Hacker News and it read
the top headline as "Git for the US US".

The actual headline was "Git for the US Code".

I raised the frame width to 672. Still wrong: "US US". At 896 it read "Git for the US
Code", exactly, and kept reading it correctly across repeated runs.

MAX_FRAMES = 12     # 12 frames is where answers stop getting better on 8GB
FRAME_WIDTH = 896   # below this, small UI text misreads
Enter fullscreen mode Exit fullscreen mode

The cost is real: about 9 seconds per question at 672px, about 16 at 896px. I paid it
without hesitating. A fast wrong answer is worth nothing, and OCR on UI text is half the
reason to run this on a screen recording in the first place.

This is the general lesson for anyone building vision products on small models: the
token cost of resolution is invisible in a benchmark and decisive in a demo. Benchmarks
resize to 224 and call it a day.

Labeling frames so citations are possible

The model cannot cite what it cannot identify. So every frame goes in preceded by its own
timestamp line, and the system prompt pins the output format:

content = [{
    "type": "text",
    "text": (f"Video: {meta['filename']}, {mmss(meta['duration'])} long, "
             f"{len(frames)} frames sampled.\n\nQuestion: {question}")
}]
for fr in frames:
    with open(os.path.join(DATA, meta["id"], fr["f"]), "rb") as fh:
        b64 = base64.b64encode(fh.read()).decode()
    content.append({"type": "text", "text": f"Frame at {mmss(fr['t'])}:"})
    content.append({"type": "image_url",
                    "image_url": {"url": f"data:image/jpeg;base64,{b64}"}})
Enter fullscreen mode Exit fullscreen mode
SYSTEM = (
    "You are Clip Detective. You are given frames sampled from ONE video, each labeled "
    "with its timestamp. The user asks a question about the video. Answer only from "
    "what is visible in the frames. Be specific and short: state the answer, then cite "
    "the frame timestamp in the form [mm:ss]. If the frames do not show it, say what "
    "you would need instead of guessing."
)
Enter fullscreen mode Exit fullscreen mode

That last sentence is the highest value line in the codebase. It converts a model that
wants to be helpful into one that is willing to say "not visible". On a clip of Big Buck
Bunny's opening shot, which contains a mossy mound and no characters, it answered that no
characters were visible and that a wider shot would be needed. A model that admits absence
is worth more than one that invents a rabbit.

The reasoning-model trap that cost me two hours

First working version returned this:

{"answer": "", "seconds": 18.1, "tokens": 400, "frames": 14}
Enter fullscreen mode Exit fullscreen mode

Empty answer, 18 seconds, 400 tokens burned. I assumed the chat template was wrong, then
that the image encoding was wrong. It was neither. LFM3-VL-2B is a reasoning model, and it
had spent the entire 400 token budget inside reasoning_content, so content came back
empty.

Two fixes, both cheap:

# run.sh
--reasoning off
Enter fullscreen mode Exit fullscreen mode
# server.py, belt and braces for any reasoning model behind this endpoint
text = (msg.get("content") or "").strip()
if not text:
    think = (msg.get("reasoning_content") or "").strip()
    text = think.split("\n\n")[-1] if think else ""
Enter fullscreen mode Exit fullscreen mode

max_tokens went from 400 to 700 too. If you take one thing from this post for your own
work: when a local model returns empty content, check the token budget and the thinking
mode before you touch anything else. Both are one-line fixes and both look like model bugs.

Metal shaders, or why the Homebrew build does not work

brew install llama.cpp gives you a build that predates LFM3. You get:

unknown model architecture: lfm3
Enter fullscreen mode Exit fullscreen mode

So I cloned master and built it. The build succeeded and the server still died, this time
inside the Metal compiler:

error: constexpr function never produces a constant expression
inline constexpr bfloat4 ggml_metal_unary_cast<bfloat4, float4>(...)
Enter fullscreen mode Exit fullscreen mode

The cause is a constexpr on two bf16 vector cast specializations that the Metal compiler
refuses to fold. Removing constexpr from both makes it compile, and the model runs on
GPU at 39.8 tokens per second instead of falling back to CPU at single digits.

That fix is now automated, because anyone else building this on a Mac will hit exactly the
same wall:

python3 scripts/fix-metal-bf16.py ~/llama.cpp-src
Enter fullscreen mode Exit fullscreen mode

It is idempotent and it does nothing if upstream has already fixed it, which is the
correct shape for a patch like this. Never ship a manual build step.

Making the timestamp clickable

An answer that says [00:04] in grey text is decoration. An answer where clicking
00:04 seeks the player and resumes playback is a tool. The whole interaction is twenty
lines of vanilla JavaScript, no framework:

function linkTimestamps(s) {
  return s.replace(/\b(\d{1,2}):(\d{2})\b/g, (m, mm, ss) =>
    `<span class="ts" data-t="${+mm * 60 + +ss}">${m}</span>`);
}

ad.querySelectorAll('.ts').forEach(el => {
  el.onclick = () => { vid.currentTime = +el.dataset.t; vid.play(); };
});
Enter fullscreen mode Exit fullscreen mode

The regex is deliberately loose so it catches timestamps the model writes mid-sentence,
not just the ones in brackets. The first version only matched [mm:ss] and half the
citations were dead.

No dependencies, on purpose

server.py is 166 lines of http.server. sampler.py is 103 lines of subprocess.
The UI is one HTML file with no build step. Total runtime dependencies: ffmpeg and
llama.cpp.

Why bother, in 2026, when fastapi exists?

Because the target is a zip that someone else runs on their own machine. Every dependency
is a chance for their Python to be wrong, their pip to be locked down, or their company
laptop to refuse an install. Standard library only means ./setup.sh has to worry about
exactly two external things, ffmpeg and a C++ build, both of which are already required.

The upload path is a good example. The browser posts the file as a raw request body with
the name in a header, and the server streams it to disk in 1MB chunks:

with open(src, "wb") as fh:
    remaining = n
    while remaining > 0:
        chunk = self.rfile.read(min(1 << 20, remaining))
        if not chunk:
            break
        fh.write(chunk)
        remaining -= len(chunk)
Enter fullscreen mode Exit fullscreen mode

No multipart parser, no werkzeug. It is twenty lines and it has never failed.

Numbers on real hardware

Apple M2 Mac mini, 8GB unified memory, macOS 15.5.

Metric Value
Generation 39.8 tok/s
Prompt ingestion 407 tok/s
Answer, scene questions (672px) ~9s
Answer, OCR questions (896px) ~16s
Model resident ~1.2GB
Cloud calls 0

Three test clips, each with ground truth I checked by hand:

clip question answer verdict
synthetic billing console, 12s "what error, and when?" ERROR 403 Forbidden from billing API [00:04] correct, the 403 sits at 4.0s
live Hacker News recording, 11s "top headline?" Git for the US Code [00:00] correct at 896px
Big Buck Bunny, 10s "name characters and action" "mossy mound, burrow, no characters visible" correct, and admits absence

I generated the first clip myself with PIL and ffmpeg specifically so I would know the
answer. If you build vision demos, make your own ground truth. Real footage is great for
vibes and useless for verification.

What is still missing

The honest list:

  • No audio. LFM3-Audio-2B is a sibling model on the same base. Transcript plus frames is the obvious v2 and it is not built.
  • No cross-video reasoning. One clip per question. "Which of these 40 recordings shows the regression" is a different, harder product.
  • Long videos degrade. Twelve frames from a 40 minute lecture is a summary, not a search.
  • Frame granularity. A 300ms flash between two sampled frames does not exist to the model, and no amount of prompting fixes that.

If you want to build on it

The repo is MIT, the whole thing is about 500 lines, and the seams are obvious:

  • sampler.py decides what the model sees. Change scene_frames, pick, MAX_FRAMES, FRAME_WIDTH.
  • server.py decides how it is asked. Change SYSTEM, ask().
  • run.sh decides which model and quant. Swap in LFM3-Omni or a bigger quant if you have the RAM.
  • static/index.html decides how it feels. One file, no build.

The features I would most like to see someone else build: a transcript lane for audio, a
two-pass sampler that finds the region of interest at low resolution then re-samples it
high, and an agent loop where the model can request "show me 00:07 again, closer". That
last one is two round trips and would beat any single large prompt.

unzip clip-detective.zip && cd clip-detective
./setup.sh
./run.sh
open http://127.0.0.1:8787
Enter fullscreen mode Exit fullscreen mode

Code & more: https://www.dailybuild.xyz/project/303-clip-detective

Top comments (0)