# Video Analyzer Dev

> Architecture, conventions, testing rules, verification workflow and release process for the mcp-video-analyzer repository. Use this skill whenever you read, write, review or debug any file in this repo — sources under src/, tests, the Dockerfile, package.json, CI workflows or the docs — and before running checks, cutting a release or publishing. It carries hard-won invariants recovered from real production bugs (yt-dlp output templates, ffmpeg crash handling, OCR frame sizing, analysis cache keys, graceful degradation) that are not derivable from reading the code, so consult it before proposing a change rather than after.

- Skill: `guimatheus92/video-analyzer-dev` (Agent Skill)
- Install (CLI): `npx skillmds@latest add guimatheus92/video-analyzer-dev`
- Raw SKILL.md: https://api.skillmd.com/api/skills/guimatheus92/video-analyzer-dev/raw
- Safety review: pending (external: skill-scanner PASS, skillspector CAUTION)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: guimatheus92 (https://skillmd.com/u/guimatheus92)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/guimatheus92/video-analyzer-dev

---


# mcp-video-analyzer — development guide

Project-internal. This is the single source of truth for contributing to this
repository; `AGENTS.md` points here rather than repeating it.

## Project

MCP server for video analysis — extracts transcripts, key frames, metadata, OCR text, and annotated timelines from video URLs (Loom, YouTube and other yt-dlp platforms, direct links) and local video files. The same engine is also exposed as a one-shot CLI (`mcp-video-analyzer analyze <url>`) and as the portable `/video` agent skill (`skills/video/SKILL.md` + Claude Code plugin).

## Commands

- `npm run check` — run ALL checks (format, lint, typecheck, knip, tests). Always run before committing.
- `npm run build` — compile TypeScript to dist/
- `npm run test` — run unit tests (vitest)
- `npm run test:watch` — run tests in watch mode
- `npm run test:smoke` — build + verify MCP server starts and responds to initialize
- `npm run test:formats` — just the video-format matrix (`test/e2e/video-formats.e2e.test.ts`); ~15s on a warm cache. Clips are all generated locally, but `detail: 'standard'` always runs OCR, so tesseract.js fetches eng+por traineddata (~7MB) once on a cold `cachePath`.
- `npm run security` — `npm audit` on shipped deps only (`--omit=dev`, fails at moderate+). The blocking tier.
- `npm run security:all` — `npm audit` on the whole tree (fails at high+); dev-only moderates deliberately don't gate.
- `npm run verify-package` — build + pack tarball + install in temp dir + verify startup (pre-publish)
- `npm run lint:fix` — auto-fix lint issues
  (**`security` is NOT part of `check`** — `check` is what `prepublishOnly` runs
  and must stay offline and deterministic. `npm audit` reads a live advisory
  database, so folding it in means an advisory published overnight fails
  `npm publish` for code that never changed. It runs as its own CI job, plus a
  weekly cron so a new advisory turns CI red without waiting for a PR.)
- `npm run format` — auto-format with Prettier
- `npm run inspect` — open FastMCP inspector for manual testing
- `node dist/index.js analyze <url> [flags]` — run the one-shot CLI against the local build (after `npm run build`)
- `npx tsx examples/generate.ts` — regenerate example outputs (run after changing tool output format, processors, or adapters)

## Architecture

- **Adapters** (`src/adapters/`) — platform-specific logic (Loom GraphQL, yt-dlp platforms [YouTube/Vimeo/TikTok/Instagram/X/Twitch/Dailymotion/Facebook], direct URL download, TwelveLabs, local files). Each implements `IVideoAdapter`. Registered most-specific-first in `server.ts`: Loom → LocalFile → YtDlp → TwelveLabs → Direct.
- **Processors** (`src/processors/`) — shared processing: frame extraction (ffmpeg + browser fallback), image optimization + OCR preprocessing (sharp), frame dedup (dHash, visual + OCR-text-aware), OCR (tesseract.js), audio transcription (whisper), annotated timeline.
- **Tools** (`src/tools/`) — MCP tool definitions registered on the FastMCP server. `analyze-core.ts` holds the shared cache + pipeline (`getAnalysis`) + content builder reused by both `analyze_video` and the batch `analyze_videos`.
- **Utils** (`src/utils/`) — URL detection, VTT parsing, temp files, in-memory + on-disk cache (`cache.ts`, `analysis-sidecar.ts`), bounded concurrency (`concurrency.ts`), env-flag parsing (`env.ts`).
- **CLI** (`src/cli.ts`) — one-shot `analyze` subcommand (`mcp-video-analyzer analyze <url>`) reusing the same `getAnalysis` pipeline: single JSON document on stdout, progress/errors on stderr, frame JPEGs copied to `--out` (default `<user-cache>/mcp-video-analyzer/<url-hash>/` via `persistentCacheDir()`) *before* `handle.cleanup()`. `src/index.ts` dispatches on `argv[2]` — no args = MCP stdio server (Docker/smithery/MCP configs rely on this). Adapter registration is shared via `registerAllAdapters()` in `server.ts`. Version literal lives in `src/version.ts`.
- **Skill + plugin** (`skills/video/SKILL.md`, `.claude-plugin/`, root `.mcp.json`) — the `/video` agent skill (Route A: MCP tools; Route B: the CLI via npx) and Claude Code plugin/marketplace manifests; the root `.mcp.json` is the plugin's bundled server config (auto-registered on `/plugin install`). Installed from GitHub, never shipped in the npm tarball (`files: ["dist"]`).

## Conventions

- TypeScript strict mode. No `any` unless explicitly necessary (use `// eslint-disable-next-line`).
- All exports must be used — knip enforces zero unused exports.
- Unit tests live next to source files: `foo.ts` → `foo.test.ts`.
- Shared test infrastructure lives in `test/`: helpers (`test/helpers/`), fixtures (`test/fixtures/`), smoke tests (`test/smoke/`), e2e tests (`test/e2e/`).
- Use `createTestImage()` from `test/helpers/images.ts` and `FIXTURES_DIR` from `test/helpers/fixtures.ts` — don't redefine in each test file.
- Use `vitest` with `pool: 'forks'` (required on Windows).
- Graceful degradation: never throw when partial results are available. Use `warnings[]` array. This includes the frame-only tools (`get_frames`/`get_frame_at`/`get_frame_burst`) **and** `analyze_moment`: a zero-frame outcome — extraction failure, or every extracted frame filtered out as black — returns `frameCount: 0` with the accumulated warnings, never a thrown `UserError` (issue #26). (Dedup can't empty a non-empty set — it always keeps `frame[0]` — so a filtered-to-zero result is always black-frame filtering.) Keep the input-validation throws — `getAdapter`, an invalid timestamp, or `to <= from` all still throw a `UserError`; **validate timestamps up front, outside the extraction `try`**, so both the download and browser strategies see an already-valid value and only the extraction outcome degrades. Wrap only the `extractFrameAt`/`extractFrameBurst` call in `try/catch` (they throw a raw ffmpeg `Error` that leaks the command line, unlike `extractKeyFrames` which degrades to `[]`); surface a fixed, path-free reason, never the caught `e.message`. Both tools' success and degraded paths emit the same JSON `{ frameCount, ..., warnings }` text block so a client can parse either uniformly.
- **`warnings[]` is public output, and every entry goes through `warningReason()`** (`src/utils/warnings.ts`). It is returned to the MCP client, read into the agent's context, and written next to the user's video under `MCP_WRITE_SIDECARS=1`, so an entry must be one line, human-readable, and free of filesystem paths and command lines. A spawned binary's `e.message` is none of those — for `execFile` it is `Command failed: <full argv>` plus the entire stderr — which is how issue #46 answered "this clip has no audio" with ~300 lines of ffmpeg banner and absolute temp paths. `ffmpegCrashReason()` (frames, from #26) and `extractYtDlpError()` (yt-dlp, which also redacts the cookie path) remain the specific translators on their own paths; `warningReason()` covers everything else and returns an already-clean single-line message untouched, so a crafted hint is never truncated. `src/utils/warning-sources.test.ts` scans all of `src/` at authoring time and fails the build on the next raw `${e.message}` — it is what found 7 of the 25 sites, the multi-line calls a line-based grep walks straight past.
- Three-strategy video download: yt-dlp (primary) → direct HTTP via Loom CDN API (fallback) → headless Chrome screenshots (last resort). **`downloadViaYtDlp()` in `src/utils/ytdlp.ts` is the only yt-dlp download implementation** — both `YtDlpAdapter.downloadVideo` and `LoomAdapter` strategy 1 call it, and it must stay that way (issue #24 was a second, divergent copy that hardcoded `.mp4`). Adapters are siblings behind `IVideoAdapter` and must not depend on each other; shared yt-dlp behaviour belongs in the util.
- **yt-dlp `-o` templates must never hardcode a container extension.** Always `-o <name>.%(ext)s` plus a `readdir` glob for `<name>.*`; when yt-dlp merges separate DASH video+audio streams it appends the REAL container to whatever template you gave it, so `-o x.mp4` writes `x.mp4.webm` and every `existsSync('x.mp4')` after it silently fails on a download that actually succeeded. Pair it with `--ffmpeg-location ffmpegPath` — without that, yt-dlp can't merge at all in our published image (no system ffmpeg) and leaves the streams separate, so the glob can return the audio-only file. The `%(ext)s` rule is enforced repo-wide by `src/adapters/ytdlp-output-template.test.ts` (which also unit-tests its own detector against the original #24 code); the `--ffmpeg-location` argument is asserted in `src/adapters/ytdlp.adapter.test.ts`.
- yt-dlp platform URLs (single-video pages only; playlists/channels rejected) route through `YtDlpAdapter`. `src/utils/ytdlp.ts` owns everything yt-dlp: `findYtDlp()` (positive probe cached per process), `runYtDlp()`, `ytdlpCookieArgs()`, `commonArgs()`, `extractYtDlpError()`, `YTDLP_MISSING` and `downloadViaYtDlp()` — always spawn through `runYtDlp` so the bin/prefix pairing can't be forgotten, and add shared flags to `commonArgs()` rather than inlining them at one call site. The `findYtDlp()` probe uses `YTDLP_PROBE_TIMEOUT` (20s, not 5s): the standalone binary cold-starts in >7s under load, and a shorter timeout made a slow-but-present binary look absent (issue #26). Raising it is nearly free — a genuinely-missing binary rejects via ENOENT in a few ms, never waiting for the timeout. Missing yt-dlp surfaces as install-hint warnings: adapter `getTranscript`/`getMetadata` throw `YTDLP_MISSING` and every tool handler catches adapter rejections into `warnings[]`; `downloadVideo` must return `null`, never reject (the pipeline calls it without catch) and reports its failure reason via the optional `onWarning` sink. Native captions preferred (uploaded > auto-generated with rolling-window collapse); `[]` from `getTranscript` strictly means "no captions exist" (fetch failures throw) → Whisper fallback.
- Standard-detail `maxFrames` default is duration-adaptive via `resolveMaxFrames()` in `detail-levels.ts` (~12 for ≤30s up to 60 for >10min). An explicit `maxFrames` always wins and keys the cache separately (`undefined` drops out of the cache key). `get_frames` keeps its fixed default of 20.
- Silent-audio gate: `transcribeAudio()` probes the track with ffmpeg `volumedetect` (first 2 min) before any Whisper strategy; mean volume ≤ −55dB skips transcription with a warning — an empty transcript on a mute track is content, not a bug.
- Frame extraction uses bundled `ffmpeg-static` — no system ffmpeg needed.
- **The bundled `ffmpeg-static` 7.0.2 LINUX build segfaults on the MPEG-TS demuxer.** Any `.mts`/`.m2ts` input (the standard AVCHD camcorder format) kills ffmpeg on probe, extract, and even remux — in the published Docker image and for every Linux user. The byte-identical file parses fine on the Windows build, so it is the binary, not the file; `ffmpeg-static@5.3.0` is the latest release, so there is no version to upgrade to. Found by `test/e2e/video-formats.e2e.test.ts`, which is exactly the blind spot it was written for. A signal-terminated ffmpeg is NOT "no frames found": `ffmpegCrashReason()` in `frame-extractor.ts` turns it into one actionable, path-free warning (never `e.message`, which is the full argv + banner), and every rewrap site in that file resolves the reason while the original error still carries `.signal`. The matrix probes the binary's actual capability rather than branching on `process.platform` — a platform check is a guess about a binary, not a fact about it — and asserts the degraded contract where the demuxer is broken, so a silently-empty result stays a failure on every platform.
- Black frame detection filters out DRM-protected/blank frames automatically.
- Scene detection threshold default: 0.1 (optimized for screencasts/demos). Use `extractKeyFrames()` (not raw `extractSceneFrames`) so static clips with no scene cuts fall back to uniform temporal sampling — critical for talking-head Reels/Stories.
- OCR runs on every frame *before* dedup; when OCR is enabled, dedup uses `dedupeKeepingTextChanges()` (visual + on-screen-text aware) so frames whose only change is the text overlay survive. Plain `deduplicateFrames()` (visual only) is used when OCR is off.
- OCR frames are preprocessed (grayscale + 2× upscale + contrast normalization + sharpen) by default; `MCP_OCR_PREPROCESS=0` disables.
- Transcription strategy order: HF transformers (opt-in) → whisper CLI → OpenAI API. HF only runs when `WHISPER_HF_MODEL` is set, so otherwise the CLI wins and its `WHISPER_MODEL`/`WHISPER_LANGUAGE` settings are never silently overridden. `model`/`language`/`initialPrompt` are overridable per call on `analyze_video`/`analyze_videos`/`get_transcript`.
- The whisper CLI is run directly (no `--help` probe — it double-imports torch and crashes on Windows on non-ASCII help text); `ENOENT` distinguishes "not installed" (try next candidate) from "installed but crashed" (warn). Spawned with `PYTHONUTF8=1`/`PYTHONIOENCODING=utf-8` so multilingual transcripts don't crash the Python stdout codec. When NO backend is configured at all, `transcribeAudio` emits an actionable "No speech-to-text backend available" warning instead of a bare `[]`.
- yt-dlp errors that look auth-related (login/cookies/private/age-restricted/empty-media/rate-limit) get a cookie hint appended by `extractYtDlpError` naming this server's env vars (`YTDLP_COOKIES` / `YTDLP_COOKIES_FROM_BROWSER`), not yt-dlp's raw CLI flags.
- Persistent sidecars (`MCP_WRITE_SIDECARS=1`) write `<stem>.vtt` (Whisper transcripts only, never clobbering an existing one) + `<stem>.analysis.json` + `<stem>.frames/` next to local videos for resumable bulk processing; reads validate `mtime:size` + params.
- CLI mode: stdout is reserved for the single JSON result document — progress, warnings-in-flight, and errors go to stderr. CLI flags validate through the shared `AnalyzeOptionsSchema` (no hand-rolled validation). Partial failures ride in `warnings[]` with exit 0; only hard failures exit 1.
- `skills/video/SKILL.md` is a public contract: any change to MCP tool names, CLI flags, or the CLI JSON shape must update `skills/video/SKILL.md` + `README.md` + the "Using this project as a tool" section of `AGENTS.md` in the same PR.
- Tesseract `.traineddata` downloads are cached in `<user-cache>/mcp-video-analyzer/tessdata` via `cachePath` (frame-ocr.ts) — never let them land in the process cwd (pollutes the agent's project dir under npx).
- **No persistent on-disk location may use a fixed name under `os.tmpdir()`.** `persistentCacheDir()` (`temp-files.ts`) resolves the OS per-user cache dir (`MCP_CACHE_DIR` overrides it; CI pins that so its `actions/cache` paths match what the code writes), and the golden-clip cache (`test/helpers/golden-clips.ts`) lives in the gitignored repo-local `.cache/golden-clips` — **not** `node_modules/.cache`, which `npm ci` wipes after the CI cache step restores it. Both were `<tmp>/mcp-video-analyzer/...` and both are gone on purpose. The one remaining `tmpdir()` fallback (no usable home, no override) is uid-keyed so two users cannot collide, and is enforced by `src/utils/tmpdir-usage.test.ts`, which scans `src/` and `test/` for any other fixed-name use. The shared temp dir is world-traversable, so a predictable path there is pre-creatable by any other local user: they read the frames the CLI copies out and can plant a `.traineddata` for our OCR to load. CodeQL flagged the test-side one (`js/insecure-temporary-file`), following the taint from the golden clips through the e2e tests into the sidecar `writeFile`s — the production one it could not see, because those sinks are `copyFile` and tesseract's own write. Per-call scratch space is different and stays in tmp: `createTempDir()` uses `mkdtemp`, which is randomized and mode-0700.
- **`detectPlatform` (`url-detector.ts`) is the network trust boundary, and `assertPublicUrl` (`ssrf-guard.ts`) is the only gate at the sinks.** All 8 MCP tools and the CLI validate through `.refine(isVideoSource)`, which is `detectPlatform(...) !== null` — so a destination refused there is refused at every entry point at once. That is why GHSA-hpmc-4g74-v53v was fixed there rather than at each caller. `detectPlatform` must stay **sync** (adapters call it from `canHandle`, and it keys the analysis cache), so it can only do literal checks: scheme, IP literals, `localhost`/`.local`, UNC. Anything needing DNS belongs in `assertPublicUrl`, which every outbound sink must call — today `downloadDirectVideo` and `extractBrowserFrames`. **Adding a new sink that takes a client URL means adding that call**; the browser fallback was an unreported second SSRF sink precisely because it was never treated as one, and it is the worse of the two (it renders the response and returns it as a JPEG). Inside it, the pre-flight call covers the first hop only — Chrome follows redirects itself and every subresource is its own request — so the `page.on('request')` interceptor is a second sink in its own right and calls `assertPublicUrl` too, asynchronously (puppeteer holds the request until continue/abort settles). A literal-only check there is not a cheaper version of the gate, it is a hole: it cannot see a hostname that merely *resolves* internal. Two things that path must keep: `data:`/`blob:`/`about:` continue unvalidated (an MSE player hands `<video>` a `blob:` URL — abort it and the fallback screenshots nothing), and both `continue()`/`abort()` are `.catch`ed, because they reject on an already-handled request and an unhandled rejection out of an event listener kills the process. ffmpeg and yt-dlp are deliberately not gated: ffmpeg only ever receives an already-downloaded `videoPath`, and `YTDLP_PATTERNS` is a real host allowlist — if either ever receives a raw URL, that changes.
- **The blocked-range table only grows.** RFC1918 + loopback + link-local is *not* enough, and shortening it to that is itself the vulnerability: Gitea's GHSA-2r5c-gw76-rh3w is CVSS 9.6 for exactly that list, because it misses CGNAT (`100.64/10`), Azure's WireServer (`168.63.129.16`), and the IPv6 transition ranges — `64:ff9b::a9fe:a9fe` reaches the AWS IMDS through NAT64. `0.0.0.0` and IPv4-mapped IPv6 (`::ffff:127.0.0.1`) are mcp-searxng's CVE-2026-54689. Matching is numeric, never string-wise, so an alternate spelling cannot slip past the metadata verdict — and that has to mean *actually* numeric: the first version of this collapsed IPv4-mapped IPv6 with a regex on the dotted spelling, which `new URL()` never produces (it normalizes `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`), so every real request walked past the entire table. `parseIp` now collapses `::ffff:0:0/96` and `::/96` on the bits. A prefix that merely *embeds* v4 without being it — NAT64 `64:ff9b::/96`, 6to4 `2002::/16` — is separately re-checked against the metadata table by `embeddedIpv4()` **before** the range verdict: the ranges refuse those prefixes only as `private`, which is exactly the verdict the opt-in unlocks, so without that step `MCP_ALLOW_PRIVATE_URLS=1` handed out the IMDS. The regression tests must assert the spelling `new URL()` emits, not the one a human would type. **Redirects are re-checked per hop** (`redirect: 'manual'` + a loop) — a first-hop-only check is the same CVE. `MCP_ALLOW_PRIVATE_URLS=1` unlocks private ranges but **never** metadata, and never the scheme check. Known, documented ceiling: DNS rebinding, since the check is at resolution and not at connection; closing it needs an `undici` `Agent({ connect: { lookup } })` and a direct dependency on undici.
- **Never pair `existsSync(p)` with a later write to `p` where losing the race would clobber someone else's data.** Use an atomic exclusive create — `writeFile(p, data, { flag: 'wx' })` — and treat `EEXIST` as the no-op. Where the race is benign because both writers produce equivalent bytes, check-then-write is fine and must say so at the call site: `test/helpers/golden-clips.ts` (first rename wins, clips are byte-equivalent by construction) and `loom.adapter.ts:302` (a did-the-download-land check) are the two deliberate instances and were correctly left alone. The sidecar `.vtt` had the check-then-write form (`js/file-system-race`) while its own header documents an external GPU-Whisper pipeline writing into the same directory, so the clobber window was real, not theoretical.

## Testing conventions

These three exist because each was violated in the issue #24 fix and caught only in review. They are cheap to follow and expensive to skip.

- **A regression guard must be proven against the real pre-fix code, pulled from git — never against a hand-written example.** The first source guard for #24 blacklisted literal extensions near `-o` and was "verified" by reintroducing the bug inline. The bug had actually been written as `const outputPath = join(destDir, \`${videoId}.mp4\`)` + `-o outputPath`, which the guard passed. Retrieve the real thing (`git show <fix-commit>^:<path>`), run the detector against it, and **pin that snippet as a test case** so the proof lives in the suite instead of in a PR description. Prefer positive assertions ("prove this is safe") over blacklists — "can't prove it" must fail, not pass.
- **A test that cannot fail is worse than no test.** `test/e2e/analyze-loom.e2e.test.ts` asserted `downloadVideo(...) === null` and called it "(no auth)"; it passed whether the code worked or not, which is why a 44MB download being silently discarded went unnoticed for months. Watch for: asserting a null/empty result as the expected outcome, a `catch → skip` broad enough to swallow real failures, and any scan-style guard that asserts nothing when it matches nothing (always assert it scanned something). Guards live in `src/adapters/ytdlp-output-template.test.ts` and `test/e2e/download-destinations.e2e.test.ts`; both unit-test their own detector.
- **When fixing a bug, grep the whole repo for siblings of the pattern before calling it fixed.** The same inverted assertion existed in `test/e2e/partial-results.e2e.test.ts` and shipped untouched in the first pass. One guard in the shared function beats a guard in every caller — and the sibling you don't look for is the one that stays broken.
- **Every container in `VIDEO_EXTENSIONS` must be decoded by a real test.** `src/utils/url-detector.ts` gates 14 extensions that all reach ffmpeg in production, but before v0.9.0 only mp4/h264 was genuinely exercised (plus one webm/vp9 probe from #24) — the rest were *string* assertions in `url-detector.test.ts` that never opened a file. A codec the bundled `ffmpeg-static` build could not decode would have returned zero frames and zero failures, since "0 frames" is valid graceful degradation everywhere else. `test/e2e/video-formats.e2e.test.ts` generates a clip per container/codec from `FORMAT_MATRIX` (`test/helpers/golden-clips.ts`) using a MOVING `testsrc` source plus a silent `anullsrc` track, then asserts routing + duration + frames>0 + the silence-gate warning (which is positive proof the audio demuxed for that container). Its drift guard fails if an extension is added to `VIDEO_EXTENSIONS` with neither a matrix row nor a documented exclusion. It stubs `MCP_WRITE_SIDECARS` empty — clips are cached across runs, so a sidecar would replay a stored result and the whole matrix would pass without ffmpeg running once.
- **Every core outcome must be asserted against ground-truth fixture content somewhere.** When graceful degradation makes an empty result valid (OCR on a blank clip, transcript on a silent track, dedup on identical frames), at least one test must exist where empty = FAIL: a fixture with *known* text/speech/cuts and an assertion that the pipeline recovered that content. The #28 OCR-downscale bug survived a 500-test suite for months because every fixture was content-free (solid colors, `testsrc`, a black clip) — 0 OCR results was simultaneously "working as designed" and "completely broken". Golden fixtures live in `test/helpers/golden-clips.ts` (ffmpeg drawtext + the OFL font in `test/fixtures/fonts/`); outcome tests in `test/e2e/golden-ocr.e2e.test.ts` and `src/processors/frame-extractor.test.ts`. The transcript half is `test/fixtures/speech.wav` (committed TTS speech; ground truth in `SPEECH_WORDS`) + `test/e2e/golden-transcription.e2e.test.ts`, gated by `WHISPER_E2E=1` because whisper is an external dependency: flag unset = suite visibly skipped (explicit operator opt-out), flag set + whisper missing = **FAIL**, never a probe-and-skip — CI always runs it with `whisper-ctranslate2` via `WHISPER_BIN` (the CLI candidate list is only `[WHISPER_BIN, 'whisper']`). A cousin of this rule guards the analysis cache: every `AnalyzeParams` leaf must be classified result-defining or excluded (`ExcludedFromCacheKey` type guard in `analyze-core.ts` + the `it.each` key table in `analyze-core.test.ts`) — #28's second bug was a result-changing param that silently missed the cache key.

## Verifying a change

`npm run check` is necessary, not sufficient — it never spawns yt-dlp, never downloads, and never installs the package. Run `npm run verify-all` (check → e2e → smoke → verify-package) before claiming a change works, and **report the actual output rather than the list of commands you intended to run**. Set `WHISPER_E2E=1` for the e2e leg when a whisper CLI is installed locally — that's the only way the transcription outcome test runs outside CI. The first #24 PR listed `npm run test:e2e` in its validation section without the full suite ever having been run; the individual files had been run instead.

For anything touching adapters or downloads, also exercise it in a container — `npm run check` passes on a machine that happens to have a system ffmpeg, while the published image has none. The #24 fix needed `--ffmpeg-location` precisely because that difference was invisible on the host.

## Environment Variables

- **Transcription:** `WHISPER_MODEL`, `WHISPER_LANGUAGE`, `WHISPER_PROMPT` (glossary → `--initial_prompt`), `WHISPER_BIN`, `WHISPER_DEVICE`/`WHISPER_COMPUTE`/`WHISPER_BEAM_SIZE`/`WHISPER_WORD_TIMESTAMPS` (env-gated — only passed to the CLI when set, so `openai-whisper` isn't broken by `whisper-ctranslate2`-only flags), `WHISPER_HF_MODEL` (opt-in), `OPENAI_API_KEY`.
- **OCR:** `MCP_OCR_PREPROCESS` (default on; `0` to disable preprocessing). OCR always reads the pre-optimization frame — recognition needs the pixels the emitted copy gives up.
- **Frame size:** `MCP_FRAME_MAX_WIDTH` (default `800`; `0`/`native`/`full`/`original` keeps source resolution) and `MCP_FRAME_JPEG_QUALITY` (default `70`, **no per-call override** — env only). The per-call `maxWidth` tool parameter (six frame-emitting tools) and the CLI's `--max-width` win over `MCP_FRAME_MAX_WIDTH`, and are the right knob for dense UI captures since the server starts once per session. A set-but-invalid value for either is rejected with a one-time stderr warning rather than silently falling back. The *effective* width (per-call → env → default) keys the analysis cache and sidecar, so the same URL at two widths can't serve one result for both — see `keyedFrameMaxWidth()`.
- **yt-dlp cookies:** `YTDLP_COOKIES` (Netscape cookie file, wins when both set) / `YTDLP_COOKIES_FROM_BROWSER` (e.g. `chrome`, `edge`) — needed for Instagram and age-restricted videos. Browser extraction requires the browser to be closed on Windows.
- **Sidecars:** `MCP_WRITE_SIDECARS` (default off; `1` to persist resumable sidecars next to local videos).
- **Cache root:** `MCP_CACHE_DIR` (absolute paths only) overrides the per-user cache dir that backs the tessdata cache and the CLI's default `--out`. Nothing reaps that location on Windows or Linux, so frames there persist until deleted; the dirs are created 0700 so the pile is at least private.
- **Network destinations:** `MCP_ALLOW_PRIVATE_URLS` (default off; `1` allows loopback/private/LAN/`.local`/UNC destinations for operators who genuinely serve video off their own network). It never unlocks cloud metadata endpoints and never relaxes the http(s)-only scheme check — see the trust-boundary convention above.
- **TwelveLabs:** `TWELVELABS_API_KEY` (opt-in Pegasus transcript/summary for direct URLs).

## Publishing

### Release Process

1. **Bump version** in `package.json`, `src/version.ts` AND `.claude-plugin/plugin.json` (must match).
2. **Run checks**: `npm run check` (format, lint, typecheck, knip, tests).
3. **Run smoke test**: `npm run test:smoke` (verifies MCP server starts and responds).
4. **Run package verification**: `npm run verify-package` (packs tarball, installs in temp dir, verifies startup).
5. **Docker image validation** — the `docker-image` CI job runs it on every PR (build from clean clone + ffmpeg present + MCP `initialize` answered), replicating what Glama CI does on each release; `dist/` is gitignored, so the image must compile itself. Confirm the job is green before releasing. Manual fallback: `git archive HEAD -o sim.tar` → extract to an empty dir → `docker build` there → pipe an MCP `initialize` into `docker run -i`. A build that only works with a locally pre-built `dist/` WILL fail on Glama and email the maintainer.
6. **Commit & push**: commit version bump to main. Confirm `Security / audit (shipped deps)`, `Security / audit (full tree)` and `CodeQL` are green too — they are separate workflows from `CI`, so a green `CI` check alone is not the whole gate.
7. **Publish to npm**: `npm publish`.
8. **Create GitHub release**: `gh release create vX.Y.Z --title "vX.Y.Z" --generate-notes`.
9. **Update local MCP config**: pin the new version in your machine's own MCP client config (NOT the repo-root `.mcp.json`, which is the plugin's bundled server config and stays on `@latest`).
10. **Verify on npm**: `npm view mcp-video-analyzer version`.

### Notes

- Source maps are disabled in tsconfig to reduce package size.
- `npm publish` runs `prepublishOnly` which executes `npm run check && npm run build` automatically.
- Never publish without testing as consumer — `npm run check` passing does NOT mean the package works for end users. Always run `npm run verify-package`.
- sharp needs no build step in the image. Since 0.35 it has **no install script at all**, and its prebuilt `@img/*` optionalDependencies declare none either, so the libvips binary comes straight out of the lockfile under `npm ci --omit=dev --ignore-scripts`. (Pre-0.35 its install script was already being skipped by that flag and sharp worked anyway — that is the proof.) `--ignore-scripts` now exists purely to skip the local `prepare` script; `npm rebuild ffmpeg-static` is still load-bearing. The `docker-image` CI job asserts the libvips binary actually loads and returns pixels, because a missing prebuilt is invisible on a dev host and fatal in the image.
- The Dockerfile is multi-stage and **self-building** (compiles `src/` in a build stage) — never make it depend on a pre-built `dist/`, and keep `src/` + `tsconfig.json` out of `.dockerignore`. The runtime stage uses `npm ci --omit=dev --ignore-scripts` (the `prepare` script would run tsc without dev deps) followed by `npm rebuild ffmpeg-static` (its postinstall downloads the ffmpeg binary; skipping it ships an image with no frame extraction). Smithery is unaffected (`smithery.yaml` launches via npx).

## Dependencies

- `fastmcp` — MCP server framework
- **Node >=22.12 is the floor** (`package.json` engines, both Dockerfile stages, README/AGENTS/SKILL.md — drift-guarded by `src/version.test.ts`). Set by `puppeteer-core@25`, which is the ONLY escape from GHSA-jmr9-qjv8-65gv in `extract-zip` (no fixed version of `extract-zip` exists; v25 drops the dependency). `sharp@0.35` independently needs >=20.9 and `vite@8` needs >=22.12. Node 18 EOL'd 2025-04 and Node 20 EOL'd 2026-04, so the floor is also just current.
- `sharp` — image processing (resize, compress, dHash computation)
- `ffmpeg-static` — bundled ffmpeg binary for frame extraction
- `puppeteer-core` — browser-based frame extraction fallback (no bundled browser)
- `tesseract.js` — OCR text extraction from frames
- `cheerio` — HTML parsing for adapter scraping

