Files
Self-Hosted-Stream-Webcam/README.md
T

411 lines
18 KiB
Markdown
Raw Normal View History

2026-09-13 20:24:20 +01:00
# 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 +/&minus; buttons | <kbd>+</kbd> <kbd>&minus;</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
```