Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
59bfd8b
chore: scaffold gateway CLI structure
Thanukamax Mar 4, 2026
341e42d
chore: add vitest and test infrastructure
Thanukamax Mar 4, 2026
c05f0b6
feat: implement grab command with ffmpeg subprocess
Thanukamax Mar 4, 2026
8fed467
docs: add README, mediamtx setup, and update CI
Thanukamax Mar 4, 2026
0add0cd
feat(spool): add spool path builder and directory helper
Thanukamax Mar 4, 2026
a15a14a
feat(sampler): add drift-free sampling loop engine
Thanukamax Mar 4, 2026
94c32f8
feat(cli): add sample command for continuous RTSP frame capture
Thanukamax Mar 4, 2026
a651e4a
docs: add sample command usage and update roadmap
Thanukamax Mar 4, 2026
fc0ce79
feat(uploader): add frame upload client for ingest service
Thanukamax Mar 4, 2026
32eee72
feat(sampler): wire optional --ingest flag for frame upload
Thanukamax Mar 4, 2026
b231e7c
fix(tests): replace vi.mocked with direct casts for bun compat
Thanukamax Mar 4, 2026
447168c
feat(cli): add multi-camera bucketing via YAML config (Phase 4)
Thanukamax Mar 4, 2026
34b5760
feat(cli): add composite mosaic for multi-camera frames (Phase 5)
prdai Mar 4, 2026
109063c
fix(config): use superRefine for Zod v4 grid validation
Thanukamax Mar 4, 2026
a79d734
feat(cli): add ONVIF motion gating for conditional HQ frame grabs (Ph…
Thanukamax Mar 4, 2026
c90a8e8
fix: address code review findings from CodeRabbit
Thanukamax Mar 4, 2026
e73c712
fix(tests): update mocks to use grabBestFrame after sampler refactor
Thanukamax Mar 16, 2026
74ef8e8
chore: resolve merge conflicts with main
prdai Mar 16, 2026
4480d5d
Merge pull request #10 from CROW-B3/feat/gateway-v1
Thanukamax Mar 16, 2026
3951291
chore: bump up version +0.0.1 [skip ci]
github-actions[bot] Mar 16, 2026
b0d79f6
build(deps): bump commander from 13.1.0 to 14.0.3
dependabot[bot] Mar 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,31 @@ permissions:
pull-requests: write

jobs:
check:
runs-on: blacksmith-2vcpu-ubuntu-2404-arm
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install

- name: Lint
run: bun run lint

- name: Build
run: bun run build

- name: Test
run: bun run test

publish-preview:
needs: check
runs-on: blacksmith-2vcpu-ubuntu-2404-arm
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
Expand Down
75 changes: 61 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,82 @@
# npm-sdk-template
# @b3-crow/cctv-cli

A ready-to-go TypeScript/JavaScript SDK package starter, with linting, testing, build & publish workflows included.
CROW CCTV Edge Ingest Gateway CLI — RTSP frame sampling, local spool, and cloud upload.

## Installation
## Install

```bash
bun install @b3-crow/npm-sdk-template
bun install
```

## Usage
## Commands

### `grab` — Grab a single RTSP frame

```typescript
import {} from '@b3-crow/npm-sdk-template';
```bash
bun run dev -- grab --rtsp "rtsp://localhost:8554/test" --out frame.jpg --timeout 10000
```

## Development
| Flag | Required | Default | Description |
| ----------- | -------- | --------- | ----------------------- |
| `--rtsp` | yes | — | RTSP stream URL |
| `--out` | no | `out.jpg` | Output file path |
| `--timeout` | no | `10000` | Timeout in milliseconds |

### `sample` — Continuously sample RTSP frames to local spool

Runs a drift-free sampling loop that grabs frames at the configured FPS and writes them to a local spool directory with deterministic `bucket_sec`-based filenames. Ctrl+C for graceful shutdown with stats summary.

```bash
# Install dependencies
bun install
bun run dev -- sample --store mystore --camera cam1 --rtsp "rtsp://localhost:8554/test"
```

| Flag | Required | Default | Description |
| ----------- | -------- | --------- | -------------------------------- |
| `--store` | yes | — | Store identifier |
| `--camera` | yes | — | Camera identifier |
| `--rtsp` | yes | — | RTSP stream URL |
| `--spool` | no | `./spool` | Spool directory root |
| `--fps` | no | `1` | Frames per second (0 < fps ≤ 30) |
| `--timeout` | no | `10000` | Per-grab timeout in milliseconds |

Output path: `<spool>/<store>/<camera>/<bucket_sec>_low.jpg`

## Development

```bash
# Build
bun build
bun run build

# Lint
bun lint
bun run lint

# Format code
bun format
# Run unit tests (no ffmpeg/RTSP needed)
bun run test

# Run with integration tests (requires RTSP stream)
RTSP_URL="rtsp://localhost:8554/test" bun run test
```

See [docs/mediamtx-setup.md](docs/mediamtx-setup.md) for setting up a local RTSP test server.

## Requirements

- [Bun](https://bun.sh) (runtime)
- [ffmpeg](https://ffmpeg.org) (RTSP frame grabbing)

## Roadmap

| Phase | Description |
| ----- | ----------------------------------------- |
| **0** | ~~Hello camera — single frame grab~~ |
| **1** | **1 FPS sampler + local spool** (current) |
| 2 | Local ingest stub |
| 3 | Cloudflare ingest MVP (R2+D1) |
| 4 | Multi-camera bucketing |
| 5 | Composite mosaic |
| 6 | ONVIF motion gating |
| 7 | Sessionization + analysis trigger |

## License

MIT
259 changes: 216 additions & 43 deletions bun.lock

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions docs/mediamtx-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Local RTSP Test Server (MediaMTX)

Use [MediaMTX](https://github.com/bluenviron/mediamtx) to run a local RTSP server for development and testing.

## Quick Start

### 1. Start MediaMTX

```bash
docker run --rm -p 8554:8554 bluenviron/mediamtx:latest
```

### 2. Push a test pattern

In a separate terminal, generate a test video stream:

```bash
ffmpeg -re -f lavfi -i testsrc=size=640x480:rate=1 -c:v libx264 -f rtsp rtsp://localhost:8554/test
```

### 3. Test the grab command

```bash
bun run dev -- grab --rtsp "rtsp://localhost:8554/test"
# → Frame saved to out.jpg (Xms)
```

### 4. Run the integration test

```bash
RTSP_URL="rtsp://localhost:8554/test" bun run test
```

## Requirements

- Docker (for MediaMTX)
- ffmpeg (for the test pattern generator and the grab command)
17 changes: 10 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@b3-crow/cctv-cli",
"version": "0.0.4",
"description": "CLI tool for capturing and streaming CCTV video+audio to the ingest service",
"version": "0.1.1",
"description": "CROW CCTV Edge Ingest Gateway CLI — RTSP frame sampling, local spool, and cloud upload",
"main": "dist/index.js",
"type": "module",
"bin": {
Expand All @@ -15,6 +15,8 @@
"dev": "bun run src/index.ts",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"test": "vitest run",
"test:watch": "vitest",
"format": "bun run prettier src/ --write",
"prepare": "husky install",
"lint-staged": "lint-staged"
Expand All @@ -31,19 +33,20 @@
},
"dependencies": {
"chalk": "^5.4.1",
"commander": "^13.1.0",
"@inquirer/prompts": "^7.5.0",
"ws": "^8.18.0"
"commander": "^14.0.3",
"sharp": "^0.34.5",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@antfu/eslint-config": "^6.2.0",
"@commitlint/config-conventional": "^20.0.0",
"@types/ws": "^8.18.0",
"commitlint": "^20.1.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.6.2",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
Expand Down
59 changes: 59 additions & 0 deletions src/commands/composite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import process from 'node:process';

import chalk from 'chalk';

import { compositeFrames } from '../lib/compositor';
import { loadStoreConfig } from '../lib/config';

interface CompositeOptions {
config: string;
spool: string;
bucket: string;
tileWidth: string;
tileHeight: string;
}

export async function compositeAction(opts: CompositeOptions): Promise<void> {
const bucketSec = Number.parseInt(opts.bucket, 10);
if (Number.isNaN(bucketSec) || bucketSec <= 0) {
console.error(
chalk.red('Error: --bucket must be a positive integer (epoch seconds)')
);
process.exit(1);
}

const tileWidth = Number.parseInt(opts.tileWidth, 10);
const tileHeight = Number.parseInt(opts.tileHeight, 10);

const config = await loadStoreConfig(opts.config);

if (!config.grid) {
console.error(
chalk.red('Error: config must include a grid definition for compositing')
);
process.exit(1);
}

console.log(
chalk.blue(
`Compositing ${config.cameras.length} camera(s) for store ${config.store_id}, bucket ${bucketSec}`
)
);

const result = await compositeFrames({
storeId: config.store_id,
bucketSec,
cameras: config.cameras,
grid: config.grid,
spoolDir: opts.spool,
tileWidth,
tileHeight,
});

console.log(chalk.green(`Composite written: ${result.jpegPath}`));
console.log(chalk.green(`Tile map written: ${result.tileMapPath}`));

const present = result.tileMap.tiles.filter(t => t.present).length;
const total = result.tileMap.tiles.length;
console.log(chalk.green(`Tiles: ${present}/${total} present`));
}
34 changes: 34 additions & 0 deletions src/commands/grab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import process from 'node:process';
import chalk from 'chalk';
import { grabFrame } from '../lib/ffmpeg';

interface GrabOptions {
rtsp: string;
out: string;
timeout: string;
}

export async function grabAction(opts: GrabOptions): Promise<void> {
const timeoutMs = Number.parseInt(opts.timeout, 10);
if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
console.error(
chalk.red('Error: --timeout must be a positive integer (ms)')
);
process.exit(1);
}

try {
const result = await grabFrame({
rtspUrl: opts.rtsp,
outPath: opts.out,
timeoutMs,
});
console.log(
chalk.green(`Frame saved to ${result.outPath} (${result.durationMs}ms)`)
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(chalk.red(`Error: ${msg}`));
process.exit(1);
}
}
Loading