2026-09-13 20:24:20 +01:00
v_2
2026-09-13 20:24:20 +01:00
v_2
2026-09-13 20:24:20 +01:00
v_2
2026-09-13 20:24:20 +01:00
v_2
2026-09-13 20:24:20 +01:00
v_2
2026-09-13 20:24:20 +01:00
v_2
2026-09-13 20:24:20 +01:00
v_2
2026-09-13 20:24:20 +01:00
v_2
2026-09-13 20:24:20 +01:00
v_2
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:

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:

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 1 2
Start / stop recording on the server Record button R
Take a photo, saved on the server Take photo button Space
Zoom in / out Scroll, pinch, or the +/ buttons +
Pan while zoomed Drag the picture arrow keys
Back to the whole frame Fit button, or double-click 0
Switch camera Tabs above the picture 1 2

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:

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:

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)

# /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
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
S
Description
No description provided
Readme
134 KiB
Languages
HTML 59.9%
Python 39.2%
Dockerfile 0.9%