Camera Feed Lab gives you a roomful of virtual security cameras without buying or wiring physical hardware. It serves configurable RTSP camera feeds from generated test patterns or approved recordings, then uses its companion Chain Monitor to check whether that video is actually arriving, advancing, and rendering in the system under test.
Use it to develop or test an NVR, VMS, video wall, player, analytics service, recorder, or other RTSP consumer. The same configuration can run on a laptop, in CI, or as a shared lab feeder. Synthetic feeds start without media files; recorded-media tests use configurable local or shared read-only mounts that remain outside the repository and image.
Camera Feed Lab is not an NVR. It does not discover cameras, record footage, manage retention, run analytics, or emulate vendor management APIs. It provides repeatable camera inputs and end-to-end verification for products that do those things.
Camera Feed Lab has two cooperating parts:
- Feed server — validates a JSON manifest, supervises MediaMTX and FFmpeg, and publishes the requested RTSP/TCP camera paths.
- Chain Monitor — independently probes delivered streams, consumes generic application telemetry, and can verify that individual regions on a physical display are neither dark nor frozen.
You can run the feed server alone. Chain Monitor is useful when a healthy publisher is not enough and the test must prove that video survived the full path through another application or display.
The default Compose stack is self-contained and generates a site-neutral synthetic compatibility profile. It needs no footage or credentials.
docker compose up --build -d
curl --fail http://127.0.0.1:8080/healthOpen a stream with an RTSP/TCP client, for example:
rtsp://127.0.0.1:8554/unicast/c1/s1/live
Stop the stack with:
docker compose downThe default host bindings are:
| Port | Protocol | Purpose |
|---|---|---|
| 8554 | RTSP over TCP | Camera-stream readers |
| 8080 | HTTP over TCP | Health and readiness, loopback-bound by default |
Deployment overrides may change host bindings. The hardened portable container supports RTSP/TCP; UDP and multicast require a broader platform-specific network deployment and are outside the default contract.
- Define 1 to 64 source streams in a strict versioned JSON manifest.
- Give each stream a stable ID, unique RTSP path, activation mode, source, and explicit video/audio behavior.
- Add up to 128 unique reader aliases per stream. Each alias publishes directly from the declared source plan and cannot be used to replace a feed.
- Generate deterministic
testsrc2or SMPTE-bar video at selected dimensions and rational frame rates. - Add an optional deterministic audio tone.
- Replay direct MPEG-PS, MPEG-TS, MP4/MOV, or MXF files once, continuously, or in wall-clock-aligned full-day mode.
- Packet-copy video or transcode it to H.264 or HEVC with selected dimensions, frame rate, bitrate, and GOP length.
- Drop, copy, or transcode audio to AAC.
- Start publishers with the service or on demand when a reader connects.
- Publish every FFmpeg feed to MediaMTX through independent flow-controlled RTSP/TCP connections.
- Wait for machine-readable readiness before starting consumer tests.
- Probe RTSP delivery using monotonic-time pacing measurements.
- Consume strict generic JSON telemetry from the system under test.
- Compare memory-only PNG/JPEG frames to detect dark or static output.
- Divide a display into independent normalized regions so moving UI chrome cannot conceal a frozen video tile.
- Require authoritative physical scanout evidence while treating browser or application screenshots as supplementary render evidence.
- Expose current health, metrics, bounded history, and an escaped HTML dashboard without exposing configured endpoints or captured frames.
- Reject unknown, duplicate, and case-variant JSON keys.
- Validate the complete manifest, media set, and FFmpeg plan before starting a child process.
- Confine media access beneath a rooted read-only media directory and pass validated files to FFmpeg by descriptor.
- Reject absolute paths, traversal, symlink escapes, special files, playlists, network sources, and unsupported containers.
- Build every child command as an executable plus argument array; manifest text is never evaluated as shell syntax.
- Restrict MediaMTX publishing and API access to loopback processes while allowing intended external readers.
- Supervise complete MediaMTX/FFmpeg process groups, drain both output pipes, forward termination signals, and preserve meaningful failure status.
- Reconstruct ephemeral runtime state from the immutable manifest after every restart.
- Emit first-party and child events as structured JSON Lines.
The canonical inputs are:
config/camera-feed-lab.schema.json— manifest schema.config/camera-feed-lab.compatibility.json— self-contained 41-producer compatibility profile.config/camera-feed-lab.synthetic-example.json— small synthetic example.config/camera-feed-lab.example.json— mixed synthetic and file-source example.docs/stream-contract.md— detailed stream and fixed-container contract.
The container reads its manifest from /config/camera-feed-lab.json, reads media below
/media, and writes generated runtime state only below /tmp. Host paths are
deployment concerns rather than image contents.
File paths in a manifest are relative to /media. A local override can mount a
custom manifest and media root read-only:
services:
camera-feed-lab:
volumes:
- ./config/custom.json:/config/camera-feed-lab.json:ro
- ./media:/media:roA shared drive uses the same container boundary with a different private bind source. Do not commit recordings, credentials, host paths, private addresses, or site mappings.
Supported direct containers are:
- MPEG-PS:
.mpg,.mpeg,.vob,.ps - MPEG-TS:
.ts,.m2ts,.mts - MP4/MOV:
.mp4,.m4v,.mov - MXF:
.mxf
Playlist and manifest formats are intentionally rejected because their nested references could escape the validated media root.
A looped full-day recording can follow current time of day:
{
"kind": "file",
"path": "sample-day.mp4",
"loop": true,
"replay": {
"mode": "wall_clock_day",
"utc_offset_seconds": 0
}
}When the publisher starts, it seeks to the corresponding seconds-of-day and then plays in real time. The historical recording retains its original content; the UTC offset and media stay in private deployment configuration.
GET /health binds before MediaMTX starts and reports the manifest's explicit
default, development, or production environment. It returns:
503while the MediaMTX API is unavailable;503while any always-on path is missing or offline;200when MediaMTX and every required always-on path are ready.
An unused on-demand path is reported as idle and does not make the service unhealthy. Successful health probes do not create log noise.
Build the current command:
go build -trimpath -o camera-feed-lab ./cmd/camera-feed-labValidate without launching children:
./camera-feed-lab validate --config ./config/camera-feed-lab.compatibility.jsonRun locally:
./camera-feed-lab run \
--config ./config/camera-feed-lab.compatibility.json \
--media-root /mediaMediaMTX hooks use the internal publish command to start a single planned
stream. Container healthchecks use the quiet healthcheck command. Run
./camera-feed-lab without arguments for the command contract.
All deployment-variable values are startup flags:
| Concern | Flags |
|---|---|
| Inputs | --config, --media-root |
| MediaMTX | --rtsp-address, --api-address, --api-url, --mediamtx |
| Health | --health-address, --health-read-header-timeout, --health-idle-timeout |
| Local publishing | --publish-base |
| Runtime state | --runtime-root |
| Demand lifecycle | --demand-start-timeout, --demand-close-after |
| Shutdown | --shutdown-timeout, --child-stop-timeout |
manifest ──► strict decode and validation ──► FFmpeg plans
│ │
│ ▼
├──► ephemeral MediaMTX configuration ──► supervised publishers
│ │
│ ▼
├──► health/readiness MediaMTX RTSP/TCP
│ │
└────────────────────────────────────────────────┴──► consumers
consumers ──► Chain Monitor probes/collectors ──► stream + display verdicts
The Go supervisor owns operating-system process state directly: child PIDs, file descriptors, pipes, sockets, signals, and exit events. Desired state is the validated manifest, so no database is needed to rebuild runtime state. This focused native boundary is recorded in the project's scoped architecture exception; all other engineering, testing, security, and review requirements remain applicable.
The implementation uses only the Go standard library. Media work is performed by pinned MediaMTX and FFmpeg runtimes rather than in the Go control process.
Chain Monitor lives under tools/chain-monitor. It runs
independently from the feed server and has no committed topology or useful
estate defaults. A deployment supplies stream endpoints, generic telemetry
collectors, display capture sources, credentials, regions, masks, and thresholds
through private read-only configuration.
Captured frame bytes and image-derived identifiers are never persisted or returned by its HTTP surface. Optional bearer tokens are read from private configuration and are not logged. Chain Monitor is development and test instrumentation, not a field-monitoring or incident-response product.
See the Chain Monitor documentation for its configuration and endpoint contracts.
Tool versions and the JSON Schema checker's dependencies are pinned under
tools/.
Run the complete local quality gate:
make checkIt runs formatting, go vet, lint, schema validation, race-enabled tests,
coverage, decomposition checks, and the rendered Compose security contract.
On a safe Docker builder, run the real-container suite:
make container-integrationThe suite exercises the actual Compose deployment, healthcheck, RTSP decode,
pacing bounds, publisher rejection, container hardening, bounded logs, reader
fan-out, and clean termination. Evidence is retained under
.artifacts/container-integration/<run-id>/; transient containers, images, and
networks are removed on both success and failure.
The public source and images contain no footage, credentials, private host addresses, site mappings, captures, or test evidence. Configuration examples use synthetic sources and generic paths. Deployments provide approved media, addresses, credentials, and mounts separately.
The runtime container uses pinned base images, a dedicated non-root identity, a read-only root filesystem, dropped capabilities, no-new-privileges, bounded PID and log budgets, and a small ephemeral tmpfs. The default network contract exposes only RTSP/TCP and loopback-bound health.
Camera Feed Lab is available under the MIT License.
