411 lines
18 KiB
Markdown
411 lines
18 KiB
Markdown
# Camera Station
|
|||
|
|
|
||
|
|
A self-hosted page for watching network cameras, recording them to a file, and
|
||
|
|
taking photos from the live picture. It handles both MJPEG feeds (MJPG-Streamer and
|
||
|
|
similar) and RTSP cameras.
|
||
|
|
|
||
|
|
Two cameras are configured out of the box:
|
||
|
|
|
||
|
|
| | Camera | Type | Address |
|
||
|
|
| --- | --- | --- | --- |
|
||
|
|
| 1 | Camera 1 | MJPEG | `192.168.69.79:8080` |
|
||
|
|
| 2 | Camera 2 | RTSP | `192.168.69.128:554` |
|
||
|
|
|
||
|
|
## Run it with Docker
|
||
|
|
|
||
|
|
docker compose up -d
|
||
|
|
|
||
|
|
Then open **http://localhost:8000**. That is the whole install: the image brings its
|
||
|
|
own ffmpeg, and the container restarts by itself after a crash or a reboot, so it
|
||
|
|
behaves as a service rather than something you have to remember to start.
|
||
|
|
|
||
|
|
Everything it produces lands in **./data** beside the compose file:
|
||
|
|
|
||
|
|
data/cameras.json your cameras
|
||
|
|
data/recordings/<camera>/ footage, in timestamped pieces
|
||
|
|
data/photos/ photos, named date_time_camera
|
||
|
|
|
||
|
|
That folder is the thing worth backing up; the image can always be rebuilt.
|
||
|
|
|
||
|
|
Useful commands:
|
||
|
|
|
||
|
|
docker compose logs -f # watch it
|
||
|
|
docker compose up -d --build # rebuild after editing any of the files
|
||
|
|
docker compose restart # after changing settings
|
||
|
|
docker compose down # stop; recordings are kept
|
||
|
|
|
||
|
|
The image is built from the `Dockerfile` here — there is nothing to download from a
|
||
|
|
registry, so all six files need to be in the same folder before you start:
|
||
|
|
|
||
|
|
Dockerfile docker-compose.yml .dockerignore
|
||
|
|
server.py index.html config.html library.html
|
||
|
|
|
||
|
|
If you see **`pull access denied for camera-station`**, Compose is trying to fetch the
|
||
|
|
image instead of building it. Run `docker compose up -d --build` once. (Earlier
|
||
|
|
versions of this compose file named the image, which invited exactly that; the current
|
||
|
|
one does not.)
|
||
|
|
|
||
|
|
Set `TZ` in `docker-compose.yml` to your own zone before you start, because photos and
|
||
|
|
recordings are named with local time.
|
||
|
|
|
||
|
|
Stopping is given 20 seconds so ffmpeg can close the file it is writing.
|
||
|
|
|
||
|
|
## Run it without Docker
|
||
|
|
|
||
|
|
Keep the three files in the same folder, then:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
python3 server.py
|
||
|
|
```
|
||
|
|
|
||
|
|
Open **http://localhost:8000**. Python 3.7+ and no pip install.
|
||
|
|
|
||
|
|
**ffmpeg is required for the RTSP camera** and only for that; the MJPEG camera needs
|
||
|
|
nothing. If ffmpeg is missing, camera 2 appears greyed out with the reason on screen
|
||
|
|
and camera 1 carries on working. Install it with `apt install ffmpeg`,
|
||
|
|
`brew install ffmpeg`, or from ffmpeg.org.
|
||
|
|
|
||
|
|
Configure cameras yourself with `--camera "Name=url"`, repeated as many times as you
|
||
|
|
like. Anything starting `rtsp://` is decoded automatically:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
python3 server.py \
|
||
|
|
--camera "Workshop=http://192.168.69.79:8080" \
|
||
|
|
--camera "Driveway=rtsp://admin:secret@192.168.69.128:554/cam/realmonitor?channel=1&subtype=0"
|
||
|
|
|
||
|
|
python3 server.py --host 127.0.0.1 # only this machine can connect
|
||
|
|
```
|
||
|
|
|
||
|
|
Names you give here are what appear on the tabs and in saved filenames.
|
||
|
|
|
||
|
|
## Using it
|
||
|
|
|
||
|
|
| Action | Control | Key |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| Switch camera | Camera tabs | <kbd>1</kbd> <kbd>2</kbd> |
|
||
|
|
| Start / stop recording on the server | Record button | <kbd>R</kbd> |
|
||
|
|
| Take a photo, saved on the server | Take photo button | <kbd>Space</kbd> |
|
||
|
|
| Zoom in / out | Scroll, pinch, or the +/− buttons | <kbd>+</kbd> <kbd>−</kbd> |
|
||
|
|
| Pan while zoomed | Drag the picture | arrow keys |
|
||
|
|
| Back to the whole frame | Fit button, or double-click | <kbd>0</kbd> |
|
||
|
|
| Switch camera | Tabs above the picture | <kbd>1</kbd> <kbd>2</kbd> |
|
||
|
|
|
||
|
|
Each camera keeps its own zoom, pan and frame-rate history, so switching back and
|
||
|
|
forth doesn't disturb how you had either one framed.
|
||
|
|
|
||
|
|
**A camera keeps recording while you watch another one.** Its tab shows a red marker
|
||
|
|
so you can see it is still running. The tally strip and the readouts always describe
|
||
|
|
the camera you are currently looking at.
|
||
|
|
|
||
|
|
The page only holds open the feeds it actually needs — the one on screen, plus any
|
||
|
|
that are recording. Switching away from an RTSP camera lets the server shut down its
|
||
|
|
ffmpeg process instead of decoding video nobody is watching.
|
||
|
|
The header carries a live clock. Below the picture, the frame rate cell shows the
|
||
|
|
current rate plus a meter of the last 60 seconds — one bar per second, auto-scaled,
|
||
|
|
with dim bars for seconds that fell well short of the usual rate and red bars for
|
||
|
|
seconds where nothing arrived at all. It makes a camera that stutters under load
|
||
|
|
obvious at a glance, which a single number does not.
|
||
|
|
|
||
|
|
**Stamp time** draws the date and time into the bottom corner of the picture itself,
|
||
|
|
so it is baked into photos and recordings rather than just shown on the page. It's off
|
||
|
|
by default. The stamp is drawn after each frame is checked for changes, so a ticking
|
||
|
|
clock never gets mistaken for a live picture — freeze the camera with the stamp on and
|
||
|
|
the meter still correctly drops to zero.
|
||
|
|
|
||
|
|
Photos download immediately and also stay in the roll at the bottom of the page so
|
||
|
|
you can save one again. The PNG/JPEG toggle sets the format.
|
||
|
|
|
||
|
|
## Recording
|
||
|
|
|
||
|
|
Each camera has a **Recording / Not recording** button on the set-up page, and the
|
||
|
|
Record button in the viewer does the same thing. When a camera is recording:
|
||
|
|
|
||
|
|
* It is recorded **continuously, on the server**, whether or not any browser is open.
|
||
|
|
Closing the page changes nothing.
|
||
|
|
* The stream is **copied, not re-encoded**. What lands on disk is exactly the
|
||
|
|
bitstream the camera sent — no quality lost, and very little CPU used. Decoding is
|
||
|
|
only needed to *watch* a camera, not to record it.
|
||
|
|
* Footage is cut into 10-minute pieces named `YYYYMMDD-HHMMSS.mkv`, so old material
|
||
|
|
can be removed a piece at a time.
|
||
|
|
|
||
|
|
Matroska (`.mkv`) is used rather than MP4 on purpose: a file that is still being
|
||
|
|
written stays playable if the machine loses power, where an unfinalised MP4 would be
|
||
|
|
lost. VLC, ffmpeg and most players open them; Windows Media Player does not.
|
||
|
|
|
||
|
|
If a camera drops off the network its recorder stops with it, and is started again
|
||
|
|
automatically once the camera answers.
|
||
|
|
|
||
|
|
### The size limit
|
||
|
|
|
||
|
|
**10 GB of footage is kept.** Once the total passes that, the oldest pieces are
|
||
|
|
deleted until it is back under, so storage never grows without bound. The piece
|
||
|
|
currently being written is never deleted, which is why usage can sit a little above
|
||
|
|
the limit for a few minutes.
|
||
|
|
|
||
|
|
The limit is the total across all cameras, not per camera. Change it in
|
||
|
|
`docker-compose.yml`, or with `--storage-limit 25` for 25 GB. Photos are not counted
|
||
|
|
against it — they are tiny by comparison — but the set-up page shows what they use.
|
||
|
|
|
||
|
|
The set-up page shows how full the store is; the **Files** page lists everything and
|
||
|
|
lets you download or delete individual items.
|
||
|
|
|
||
|
|
### How long it has been running
|
||
|
|
|
||
|
|
The recording timecode and the uptime figure both count in days, hours, minutes and
|
||
|
|
seconds — `3d 04:05:06`. The day marker only appears once there is a day to show, so
|
||
|
|
a short recording still reads plainly as `00:04:12`.
|
||
|
|
|
||
|
|
This matters more than it sounds for a service that is meant to be left alone:
|
||
|
|
counting only minutes and seconds would quietly wrap back to zero every hour, and a
|
||
|
|
camera that had been recording for a week would look like it had just started.
|
||
|
|
|
||
|
|
## Photos
|
||
|
|
|
||
|
|
**Take photo** saves straight to the server. Nothing is downloaded and nothing asks
|
||
|
|
where to put it. Files are named:
|
||
|
|
|
||
|
|
20260721_143052_Driveway.png date_time_cameraname
|
||
|
|
|
||
|
|
Two photos inside the same second get `-2`, `-3` and so on rather than overwriting
|
||
|
|
each other.
|
||
|
|
|
||
|
|
What is saved is exactly what you are framing: if you have zoomed in, the photo is
|
||
|
|
the cropped region at its own pixel count, cut from the original frame with no
|
||
|
|
resampling. The roll under the picture lists what has been saved and links to each
|
||
|
|
file if you also want a copy on the machine you are sitting at.
|
||
|
|
|
||
|
|
## Browsing what has been saved
|
||
|
|
|
||
|
|
**http://localhost:8000/files**, or *Files* from the viewer.
|
||
|
|
|
||
|
|
Two tabs. **Recordings** lists every stored segment newest first, grouped by day,
|
||
|
|
with the camera it came from and its size; **Photos** shows a thumbnail grid. Both
|
||
|
|
can be filtered by camera and by date, and every entry has a Download link.
|
||
|
|
|
||
|
|
The segment a camera is writing into right now is marked **recording now**. You can
|
||
|
|
still download it — you will get it as far as it had got — and its Download button
|
||
|
|
says *Download part* so that is not a surprise. It is also the one file the delete
|
||
|
|
button refuses to remove, since deleting it would not stop the recording, only lose
|
||
|
|
the footage being captured.
|
||
|
|
|
||
|
|
Selecting files and pressing **Delete selected** removes them for good, after a
|
||
|
|
confirmation. This is the same store the 10 GB limit applies to, so deleting by hand
|
||
|
|
simply buys time before the automatic clean-up needs to.
|
||
|
|
|
||
|
|
Photos open in a tab when clicked. Recordings do not: they hold the camera's own
|
||
|
|
stream in a Matroska container, which browsers will not play. Download them and use
|
||
|
|
VLC, or convert one without re-encoding:
|
||
|
|
|
||
|
|
ffmpeg -i 20260721-143000.mkv -c copy 20260721-143000.mp4
|
||
|
|
|
||
|
|
## Adding and editing cameras
|
||
|
|
|
||
|
|
Open **http://localhost:8000/config**, or follow *Set up cameras* from the viewer.
|
||
|
|
|
||
|
|
Add as many cameras as you like. The address decides how each one is handled: a
|
||
|
|
`http://` address is treated as an MJPEG feed and relayed untouched, while an
|
||
|
|
`rtsp://` address is decoded by ffmpeg on this machine.
|
||
|
|
|
||
|
|
**Test** pulls a single frame from a camera and reports its resolution, so you can
|
||
|
|
check an address before you commit to it. It reports the real reason when it fails
|
||
|
|
too — wrong password, connection refused, nothing listening.
|
||
|
|
|
||
|
|
Cameras are saved to **cameras.json** next to `server.py` and are picked up the next
|
||
|
|
time the server starts. The viewer notices changes within a few seconds, so you do
|
||
|
|
not need to reload it — except while a recording is running, when the update politely
|
||
|
|
waits until the recording stops rather than interrupting it.
|
||
|
|
|
||
|
|
Editing one camera leaves the others alone. Renaming does not restart anything, and a
|
||
|
|
camera whose address is unchanged keeps streaming without a flicker.
|
||
|
|
|
||
|
|
### Passwords are write-only
|
||
|
|
|
||
|
|
The set-up page shows a saved password as bullets and never receives the real one.
|
||
|
|
Leave the bullets alone to keep the existing password; type over them to set a new
|
||
|
|
one. The bullets are never written to disk, so an edit that never saw a password
|
||
|
|
cannot destroy it.
|
||
|
|
|
||
|
|
### Read-only mode
|
||
|
|
|
||
|
|
python3 server.py --lock-config
|
||
|
|
|
||
|
|
The set-up page still lists the cameras, but saving is refused. Worth using if the
|
||
|
|
page is reachable by anyone you would not hand the camera passwords to — note that
|
||
|
|
`--lock-config` only stops edits, it does not add a login.
|
||
|
|
|
||
|
|
Cameras named with `--camera` on the command line win for that run and are not
|
||
|
|
written to `cameras.json`.
|
||
|
|
|
||
|
|
## Previews on the tabs
|
||
|
|
|
||
|
|
Each camera tab carries a small live preview.
|
||
|
|
|
||
|
|
The camera you are watching updates continuously, drawn from the frame already on
|
||
|
|
screen, so it costs nothing. Cameras you are **not** watching are not connected, so
|
||
|
|
their preview is a still that refreshes on a timer.
|
||
|
|
|
||
|
|
That refresh is not free for an RTSP camera — the server has to run ffmpeg briefly to
|
||
|
|
produce each still — so the rate is yours to choose with the **Previews** button:
|
||
|
|
5s, 15s (the default), 60s, or off. The choice is remembered per browser, so a phone
|
||
|
|
and a desktop can disagree about it. Set it to *off* and the previews simply freeze at
|
||
|
|
the last picture each camera showed.
|
||
|
|
|
||
|
|
## Cameras that came set up
|
||
|
|
|
||
|
|
Camera 1 is the MJPEG feed at `192.168.69.79`. Camera 2 is the RTSP camera at
|
||
|
|
`192.168.69.128`, already wired to your address. Add or change them on the set-up
|
||
|
|
page; these are only the starting point.
|
||
|
|
|
||
|
|
Each camera keeps its **own zoom, pan and frame history**. Zoom into one, switch
|
||
|
|
away, switch back, and it is still framed exactly where you left it.
|
||
|
|
|
||
|
|
The page connects only to the camera you are looking at. Switching away from an RTSP
|
||
|
|
camera lets the server stop decoding it — recording is unaffected, because that runs
|
||
|
|
separately and does not decode anything.
|
||
|
|
|
||
|
|
### RTSP needs ffmpeg
|
||
|
|
|
||
|
|
No browser can play RTSP, so the server decodes it with ffmpeg and hands the page
|
||
|
|
MJPEG. MJPEG cameras need nothing extra. The Docker image already contains ffmpeg;
|
||
|
|
if you are running `server.py` directly you will need it installed:
|
||
|
|
|
||
|
|
winget install ffmpeg # Windows
|
||
|
|
sudo apt install ffmpeg # Debian/Ubuntu
|
||
|
|
brew install ffmpeg # macOS
|
||
|
|
|
||
|
|
Without it, RTSP cameras appear disabled with the reason on screen, and MJPEG
|
||
|
|
cameras carry on working.
|
||
|
|
|
||
|
|
**Your camera password never reaches the browser.** It stays on the server. The page
|
||
|
|
is told only `rtsp · 192.168.69.128:554`.
|
||
|
|
|
||
|
|
## Zoom, and what it does to quality
|
||
|
|
|
||
|
|
The camera sends one fixed resolution, so zoom here is a crop rather than a lens.
|
||
|
|
The page is built so that cropping never costs anything it doesn't have to:
|
||
|
|
|
||
|
|
- Every frame is kept in a master canvas at the camera's native resolution, untouched
|
||
|
|
and unstamped. The view, photos and recordings are each drawn from it directly, so
|
||
|
|
no output is ever built from an already-resampled picture.
|
||
|
|
- The on-screen canvas is backed at your display's real pixel density, so zooming in
|
||
|
|
reveals detail that was always in the stream but too small to make out.
|
||
|
|
- Crops are snapped to whole source pixels. A fractional crop boundary would force the
|
||
|
|
browser to resample even at 1:1.
|
||
|
|
- **A zoomed photo is a pixel-exact cut of the original.** Zoom 2x into a 1280x720 feed
|
||
|
|
and you get a 640x360 file whose pixels are identical to that region of the full
|
||
|
|
frame, rather than a 1280x720 file padded out with interpolated detail. Smaller file,
|
||
|
|
no invented information. The overlay always shows the exact pixel size you will get.
|
||
|
|
- The overlay also reports how hard the picture is being magnified to fill the viewport
|
||
|
|
(`screen 3.4x`). A modest camera on a large monitor is magnified even at 1x, so this
|
||
|
|
is stated plainly: you can tell when you are looking at real detail and when you are
|
||
|
|
not.
|
||
|
|
|
||
|
|
### Watching costs a re-encode. Recording does not.
|
||
|
|
|
||
|
|
These are two different paths, and it is worth being clear about which is which.
|
||
|
|
|
||
|
|
**Recording is lossless.** The stream is copied to disk exactly as the camera sent it,
|
||
|
|
H.265 and all. Nothing is decoded, nothing is re-encoded, nothing is scaled. This is
|
||
|
|
also why an active camera barely registers on the CPU.
|
||
|
|
|
||
|
|
**Watching an RTSP camera costs one re-encode.** No browser can play RTSP, so ffmpeg
|
||
|
|
decodes it and hands the page JPEG frames, at `-q:v 3` and full resolution. That
|
||
|
|
re-encode is unavoidable for anything you want to see in a browser — but it only
|
||
|
|
happens while somebody is actually looking.
|
||
|
|
|
||
|
|
So the expensive thing is watching, not recording, and it stops when you close the
|
||
|
|
page. If watching costs more CPU than you would like:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
python3 server.py --rtsp-quality 6 # smaller JPEGs
|
||
|
|
python3 server.py --rtsp-fps 10 # fewer frames
|
||
|
|
python3 server.py --rtsp-size 1280x720 # smaller picture
|
||
|
|
```
|
||
|
|
|
||
|
|
Switching the camera URL to `subtype=1` uses the camera's own substream and is usually
|
||
|
|
the cheapest option of all. It affects what gets recorded too, so use it only if the
|
||
|
|
substream is good enough to keep.
|
||
|
|
|
||
|
|
**Recorded footage ignores zoom entirely.** Zoom is a viewing control; what reaches
|
||
|
|
the disk is always the camera's full frame. Zoom and pan as much as you like while a
|
||
|
|
camera is recording without touching what is saved.
|
||
|
|
|
||
|
|
To turn a recording into MP4 without re-encoding it:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
ffmpeg -i 20260721-143000.mkv -c copy 20260721-143000.mp4
|
||
|
|
```
|
||
|
|
|
||
|
|
## Credentials
|
||
|
|
|
||
|
|
RTSP URLs usually carry a username and password. Those stay in the server process:
|
||
|
|
the page is told only `rtsp · 192.168.69.128:554`, and the password appears in no
|
||
|
|
response the browser ever receives. Passwords containing `!`, `@` or other awkward
|
||
|
|
characters are safe — nothing is passed through a shell.
|
||
|
|
|
||
|
|
Anyone who can reach the page can watch the cameras, so bind to `127.0.0.1` or keep
|
||
|
|
it behind your LAN if that matters.
|
||
|
|
|
||
|
|
## Why there's a server
|
||
|
|
|
||
|
|
A browser blocks reading pixels back out of a canvas that has drawn an image from
|
||
|
|
another origin. That would break both photo capture and recording. `server.py` serves
|
||
|
|
the page and relays the camera feed on the same origin, so the canvas stays readable.
|
||
|
|
The relay is also the only thing that talks to the camera — the browser never
|
||
|
|
connects to port 8080 directly.
|
||
|
|
|
||
|
|
## Notes
|
||
|
|
|
||
|
|
- With `--host 0.0.0.0` (the default) anyone on your LAN who finds the port can watch.
|
||
|
|
There is no password. Use `--host 127.0.0.1`, or put it behind your reverse proxy, if
|
||
|
|
that matters.
|
||
|
|
- Recording captures each frame as it arrives, so the video runs at the camera's own
|
||
|
|
rate rather than a fixed one. Cameras faster than your monitor's refresh rate will
|
||
|
|
drop frames; anything up to 30 fps is captured 1:1.
|
||
|
|
- Photos are taken from the frame already on screen, so they never interrupt the feed.
|
||
|
|
- If the camera drops out, the page says so and keeps retrying on its own.
|
||
|
|
|
||
|
|
## Running it in the background without Docker
|
||
|
|
|
||
|
|
Docker already does this. If you would rather not use it:
|
||
|
|
|
||
|
|
**Linux (systemd)**
|
||
|
|
|
||
|
|
```ini
|
||
|
|
# /etc/systemd/system/camera-station.service
|
||
|
|
[Unit]
|
||
|
|
Description=Camera Station
|
||
|
|
After=network-online.target
|
||
|
|
|
||
|
|
[Service]
|
||
|
|
ExecStart=/usr/bin/python3 /opt/camera-station/server.py --host 0.0.0.0
|
||
|
|
WorkingDirectory=/opt/camera-station
|
||
|
|
Environment=CAMERA_STATION_DATA=/var/lib/camera-station
|
||
|
|
Restart=always
|
||
|
|
RestartSec=5
|
||
|
|
KillSignal=SIGINT
|
||
|
|
TimeoutStopSec=20
|
||
|
|
User=youruser
|
||
|
|
|
||
|
|
[Install]
|
||
|
|
WantedBy=multi-user.target
|
||
|
|
```
|
||
|
|
|
||
|
|
```bash
|
||
|
|
sudo systemctl enable --now camera-station
|
||
|
|
```
|
||
|
|
|
||
|
|
`KillSignal=SIGINT` and the stop timeout matter: they let ffmpeg close the file it is
|
||
|
|
writing rather than having it cut off.
|
||
|
|
|
||
|
|
**Windows**
|
||
|
|
|
||
|
|
Run it as a scheduled task set to *Run whether user is logged on or not*, triggered
|
||
|
|
*At startup*, with `pythonw.exe` so no console window appears:
|
||
|
|
|
||
|
|
```
|
||
|
|
Program: C:\Path\To\pythonw.exe
|
||
|
|
Arguments: server.py --host 0.0.0.0
|
||
|
|
Start in: C:\Users\JV\Desktop\files
|
||
|
|
```
|