# dsh-video-see
**Give your DeepSeek Harness agent real eyes on video — one tool call, N real images, each labelled with its timestamp.**
[中文](README.md)
---
## The problem
Ask a DSH agent "what happens in this video?" and what it needs is the **picture**.
Today it has two options, and both are bad:
| Option | Cost |
|---|---|
| A vision-bridge plugin (frames go out to some cloud VLM, text comes back) | Another API key, another bill, and the result is **second-hand**: when you follow up with "what does that small text in the top right say?", the frames are no longer in context |
| Let the agent hand-roll ffmpeg | Rebuild the command every time, count timestamps by hand, and try not to sample past the end of the stream into an empty seek |
**This plugin takes a third path: it does not look for you, it hands you the frames.**
No external VLM. No API key. **Not one byte of the picture leaves the machine.** It does four things:
```
probe → decide which moments matter → pull those frames with ffmpeg → return them as real image blocks
```
### Why this works: DeepSeek-V41-Flash already has native vision
In the `dsh-llm-deepseek` catalog, `deepseek-flash` (display name **DeepSeek-V41-Flash**) declares
`inputModalities: ["text", "image"]` — **it can already see**. The text-only entries are
`deepseek-v4-flash` and `deepseek-v4-pro`. So for a model that already reads images, routing them
through a second model is a straight downgrade.
And DSH's image pipeline is generous (from the same package's README and source):
- `maxImagesPerRequest: 600` — six hundred images in a single request
- **384 tokens maximum per image** (the published v4 vision accounting: 14px patch grid, 3:1 downsampling)
- Uploaded through the DeepSeek Files API, with automatic base64 fallback
That means **20 frames ≈ 7,680 tokens**. Don't starve the frame count to save tokens — starve it and
you lose the answer to "when did it change?"
> **Video itself has no channel.** DSH's provider modality enum is literally `["text", "image"]`, and the
> attachment service only accepts `image/*`. So turning a video into frames is not a workaround — it is
> the only native path there is.
## Install
```bash
dsh plugin --profile web add dsh-video-see
```
Then **restart `dsh web`** (the plugin tree is composed at process start).
One prerequisite: **ffmpeg / ffprobe on PATH** (or point at them in the config below).
Zero runtime dependencies, no build step, no network.
## Use
There is nothing to memorise — just ask:
> "What happens in `D:\videos\demo.mp4`?"
> "What does the small text at 14 seconds say?"
> "How many cuts does this clip have?"
The model calls `video_see` on its own. Parameters:
| Parameter | Type | Meaning |
|---|---|---|
| `input` | string (required) | Video path; any container ffmpeg can decode |
| `times` | array | Exact moments — seconds (`12.5`) or clock strings (`"01:30"`). **Highest priority**; overrides `every`/`count` |
| `every` | number | One frame every N seconds |
| `count` | integer | Evenly spaced frames (1–64, default 8) |
| `start` / `end` | number | Sample only inside a window |
| `width` | integer | Max width per frame (default 768); **total pixels are capped separately**, so portrait survives |
| `mode` | string | `frames` (default: one image per frame, full detail) / `sheet` (one tiled contact sheet, smallest context) |
The result is one text block plus N images:
```
D:\videos\demo.mp4
video
12.4 s, source 1080x1920 px, frames 404x718 px, audio track: present (not transcribed)
image 1 = 0:00.000
image 2 = 0:01.771
...
```
**`frames` or `sheet`?** Depends on the question:
- **Take in the whole clip** → `sheet`: one image, 384 tokens, cheap. Per-cell detail is low, so
**never read small on-screen text out of a sheet**.
- **Read details** (subtitles, UI text, expressions, motion stages) → `frames`: each is also capped at
384 tokens, so pulling a dozen is cheap. Still not enough? Ask again with `times` for that one second.
## Configuration
All optional, in your profile's `cordis.patch.yml`:
```yaml
- id: video-see
name: dsh-video-see
config:
# ffmpegPath: D:\ffmpeg\bin\ffmpeg.exe # also honours DSH_FFMPEG_PATH / FFMPEG_PATH
# ffprobePath: D:\ffmpeg\bin\ffprobe.exe # also honours DSH_FFPROBE_PATH / FFPROBE_PATH
maxFrames: 64 # per-call frame cap (1-64)
maxWidth: 768 # per-frame width cap
maxPixels: 640000 # per-frame area cap; matches DSH's default imagePixelBudget
quality: 3 # JPEG quality (ffmpeg -q:v, 2 best / 31 worst)
timeoutMs: 120000 # per ffmpeg invocation
toolTimeoutMs: 600000 # whole tool call
graceMs: 2000 # termination grace before the hard kill
```
## Rules the code holds itself to
Every one of these has an assertion behind it:
- **Stay off the tail.** Even spacing puts the final frame exactly on `duration`, and **there is no
frame at the duration boundary**: a 3.000 s clip's last frame sits at 2.96 (25 fps), so `-ss 3.000`
writes nothing at all. This bites on **every single call**, so the edge is derived from the frame rate.
- **Portrait scales by area.** Capping width alone leaves 2160×4096 at 768×1456 ≈ 1.12M pixels —
nearly double the budget.
- **No downscale means no touch.** Forcing 101 px to an even 100 px would quietly change the aspect
ratio, so `resized: false` emits no `-vf` and returns the source dimensions untouched.
- **Honour rotation.** Phone video is often "1920×1080 encoded + 90° rotation": ffmpeg autorotates on
decode while ffprobe reports the coded size. Planning `scale` from the coded size squashes the picture.
- **Refuse before the expensive part.** A model that does not declare image input is rejected
**before any decoding happens**.
- **No shell.** argv arrays throughout.
- **Temp frames never touch the disk for long** (removed in `finally`).
- **Failures speak plainly.** ffprobe/ffmpeg errors carry the stderr tail, the offending frame and its timestamp.
## Tests
```bash
npm test # 41 assertions: pure functions + real-ffmpeg end to end
node scripts/verify-host-contract.mjs # validates schema and return value with the host's own validators
```
The "host" in `npm test` is a stub, but it **validates like the real one**: the attachment stub checks
JPEG magic bytes and reads real pixel dimensions, so "ffmpeg didn't actually produce a frame" blows up
in the test run. Fixtures are generated on the fly (`testsrc2` + a sine track) — no binary video in the repo.
`scripts/verify-host-contract.mjs` is a second layer: it imports
`assertSupportedJsonSchema` / `assertObjectJsonSchema` / `validateJsonSchemaValue` straight out of the
host installation and checks the real thing.
> Why it exists: the schema checker inside the unit tests is a **replica** (the plugin has zero
> dependencies, so the test environment cannot reach `@deepseek-ai/dsh-tools`). If the replica is wrong,
> the unit tests are a wall of false green. And an unsupported keyword (`minimum` / `maxItems` /
> `pattern` …) is not "a tool that behaves oddly" — `ctx.tools.register()` throws during `apply()`, which
> means **the plugin fails to install at all**.
## Known limits
- **No ears.** Audio is never transcribed; `hasAudio` only reports whether a track exists.
**Do not guess dialogue from the picture** — the tool description tells the model the same thing.
- **Not mediated by `ctx.fs`.** `input` is handed straight to ffmpeg, so the agent-side file sandbox
does not apply to this read (same as `dsh-ffmpeg` and friends). It only ever writes inside its own
private temp directory.
- **Frames are discrete snapshots.** Motion between frames and fleeting events are missed; use `times`
to densify a sensitive stretch.
- **Needs ffmpeg.** Without it, nothing runs.
## License
MIT.