v_2
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user