External cameras in HTTP-snapshot mode failed to load with a repeating
"connection lost" when the endpoint served PNG/WebP/BMP stills instead of
JPEG (common on IP cameras and reverse-proxied snapshot URLs). The URL
rendered fine directly in a browser, but Bambuddy's MJPEG stream wraps
every part in a hard-coded Content-Type: image/jpeg boundary, so a
non-JPEG payload labelled as JPEG made the browser reject the frame and
tear down the whole multipart/x-mixed-replace stream.
_capture_snapshot now transcodes non-JPEG stills to JPEG via OpenCV
(already a dependency). Genuine JPEG snapshots keep a byte-for-byte fast
path; truly undecodable responses (HTML error pages, auth redirects) fall
back to the previous raw-return behaviour with a single clear warning
instead of a per-frame log flood.
A previous attempt swapped `-timeout` → `-stimeout` unconditionally to
fix EADDRINUSE on the reporter's transitional ffmpeg. That broke every
install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
— where `-stimeout` was removed and `-timeout` is back to meaning
socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
"Unrecognized option 'stimeout'" on ffmpeg 7.1.
install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
— where `-stimeout` was removed and `-timeout` is back to meaning
socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
"Unrecognized option 'stimeout'" on ffmpeg 7.1.
ffmpeg has shipped THREE arrangements of this option over time and
Bambuddy supports the full range:
- Pre-deprecation (early 4.x and earlier): `-timeout` is socket I/O.
- Transitional (~late-4.x, Jammy-era): `-timeout` is deprecated and
repurposed to RTSP listen-mode timeout; any non-zero value implies
`-listen`, which makes ffmpeg bind the TLS-proxy port and fail with
EADDRINUSE. `-stimeout` is the replacement socket I/O option.
- Modern (5.x / 6.x / 7.x): `-stimeout` REMOVED. `-timeout` is back to
socket I/O — the original meaning.
So no single literal is correct on all installs.
Fix: `rtsp_socket_timeout_flag()` in services/camera.py probes
`ffmpeg -h demuxer=rtsp` once and picks `-stimeout` when ffmpeg
advertises it (covers transitional + older builds that kept it as an
alias), else `-timeout` (modern + pre-deprecation). Cached at module
level for the process lifetime — ffmpeg doesn't swap mid-run.
The function returns the option name without a leading dash; callers
prepend it themselves so a formatting bug can't pass an empty flag.
Wired into both RTSP ffmpeg call sites in lockstep: routes/camera.py
(printer camera) and services/external_camera.py (external RTSP),
which use the same TLS-proxy + ffmpeg pattern and would hit the same
regression on either ffmpeg cohort.
Tests: 8 in test_ffmpeg_rtsp_timeout_flag.py — 6 probe unit tests
(prefers stimeout when advertised, falls back to timeout on modern,
defaults to timeout when ffmpeg missing or probe raises, caches across
calls, trailing-space substring guard against `-listen_timeout`
false-positives), 2 parametrised guards against either RTSP ffmpeg
argv re-hard-coding a literal instead of consuming the probe. 37
probe + existing external-camera tests green.
go2rtc and several IP cameras still emit a warm-up / black frame on every
fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
slipped through intermittently for @nkm8's setup. His own bisect named
the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
single-frame endpoint that never returns the encoder's stale keyframe.
Adds an optional external_camera_snapshot_url column on printers. When
set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
on the override URL via plain HTTP GET, bypassing the warm-up dance.
Live view stays on the configured stream URL — only single-frame
captures use the override. Override is camera-type-agnostic. SSRF guard
applies (existing _sanitize_camera_url allowlist). Empty string treated
as unset.
Settings UI: new "Snapshot URL (optional)" input + Test button under
External Cameras, hidden for camera_type=snapshot since the live URL is
already a single-frame source. en + de fully translated; 6 other locales
seeded with English copy.
5 backend tests pin the routing contract; 3 frontend tests pin the
input + debounced PATCH. Documented in
bambuddy-wiki/docs/features/camera.md with the go2rtc example.
_capture_mjpeg_frame returned the very first JPEG it found in the
bytes stream, but many MJPEG sources — go2rtc most notably, and
several IP cameras — emit a warm-up frame on the byte that follows
connection accept: usually the last keyframe held in the encoder,
typically black or stale until the encoder catches up to live
content. Subsequent frames on the same connection are fine.
Result: every code path that opened a fresh capture (snapshot UX,
finish photos in notifications, timelapse, plate-detection CV,
Obico ML inference, Settings → Test button) returned a black image
on go2rtc-fronted cameras.
Reporter's support log showed every black frame was 11095 bytes
(pure-black 1280x720 JPEG ≈ 10-15 KB) while real-content frames
from the same source were 30-45 KB.
Fix:
- Read past the first complete JPEG, return the second.
- Fall back to the first frame if the connection closes / times out /
hits the 5 MB buffer cap before a second arrives. Without that
fallback, slow / single-frame streams that pre-fix returned the
warm-up would post-fix return None — a regression. The fallback
guarantees we never do worse than current behaviour.
- Inner while-loop now drains every complete frame already in the
buffer before pulling the next chunk so high-FPS sources that
pack multiple frames per chunk are handled correctly.
Untouched: snapshot / rtsp / usb capture paths, generate_mjpeg_stream
(live-view fan-out).
7 new regression tests in TestCaptureMjpegFrameWarmupSkip cover
two-frames-in-two-chunks, two-frames-in-one-chunk, partial-frame-
split-across-chunks, single-frame fallback, timeout fallback, zero-
frame stream returns None, non-200 returns None.
Latency penalty: at most one frame interval (typically 50 ms - 1 s
on a steady stream), well within every caller's tolerance window.
The Debian ffmpeg package uses GnuTLS, whose hardened defaults reject
TLS renegotiation and legacy ciphers that some Bambu printer firmwares
(notably P2S) rely on — causing RTSP sessions to drop after a few
seconds.
Add a local TLS termination proxy (Python ssl/OpenSSL) that handles
the TLS connection to the printer and exposes a plain RTSP port to
ffmpeg. The proxy rewrites RTSP request-line URLs (rtsp://proxy →
rtsps://printer) while preserving Authorization headers so Digest
auth hashes remain valid.
Also:
- Reduce RTSP reconnect delay from 1.0s to 0.2s
- Add ffmpeg fast-start flags (-probesize 32, -analyzeduration 0,
-fflags nobuffer, -flags low_delay)
- Fix external camera double rate-limiting causing choppy streams
- Apply TLS proxy to external camera rtsps:// URLs and snapshot capture
- Update orphan ffmpeg cleanup to match rtsp:// (proxied) URLs
- Add unit tests for RTSP URL rewriting and proxy lifecycle
The snapshot endpoint always used the internal printer camera even when
an external camera was configured. Now checks for external camera first,
matching the stream endpoint pattern. Also added retry logic (3 attempts,
2s delay) to MJPEG and RTSP stream generators so they reconnect on
timeout instead of silently ending the stream.
There were several instances where print() debugging was used instead of
or in addition to formal logging. These messages were tagged `[EXT-CAM]`.
Where there was already a logging call, the print statement was removed.
Where there was not, logger.debug() was used as a replacement.
Signed-off-by: Jeff Kletsky <git-commits@allycomm.com>
- Add _sanitize_camera_url() that returns reconstructed URL from
validated and parsed components, breaking CodeQL's taint tracking
- Update _capture_mjpeg_frame, _capture_snapshot, _stream_mjpeg to
use sanitized URLs instead of original user input
- Keep _validate_camera_url as legacy wrapper for backwards compat
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Block access to cloud metadata services and dangerous destinations:
- AWS/GCP/Azure metadata endpoint (169.254.169.254)
- GCP internal metadata hostnames
- localhost and loopback addresses
- All link-local addresses (169.254.x.x)
Local network IPs (192.168.x.x, 10.x.x.x) are still allowed since
cameras are typically on the same LAN as the server.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Path traversal: Convert device number to integer to break taint chain,
use strict /dev/videoN validation with range limit
- SSRF: Add documentation explaining intentional SSRF for user-configured
external camera URLs, add lgtm suppression comments
- Info exposure: Don't expose exception messages in plate calibration
errors, only expose error type name
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add USB camera type to external camera service
- Auto-detect available V4L2 devices on Linux
- New API endpoint: GET /api/v1/printers/usb-cameras
- Use ffmpeg for USB camera capture and streaming
- Add "USB Camera (V4L2)" option in Settings UI
- Debounce camera URL input to avoid saving on every keystroke
Closes#143
Add support for external network cameras (MJPEG, RTSP, HTTP snapshot)
that replace a printer's built-in camera when configured.
Features:
- Live streaming on printers page (replaces built-in camera)
- Finish photo capture from external camera on print complete
- Layer-based timelapse: captures frame on each layer change,
stitches to MP4 video on print completion
Backend changes:
- Add external_camera_url, external_camera_type, external_camera_enabled
fields to Printer model with database migration
- New external_camera.py service: MJPEG/RTSP/snapshot frame capture,
connection testing, MJPEG stream generation
- New layer_timelapse.py service: TimelapseSession management,
layer-by-layer frame capture, ffmpeg video stitching
- Add on_layer_change callback to MQTT client and printer manager
- Update camera routes with external camera streaming and tracking
- Update print lifecycle hooks for timelapse start/stitch/cancel
- Add external camera fields to backup/restore
- Rate limiting for external camera streams (prevents browser freeze)
Frontend changes:
- Add external camera configuration UI in Settings > Camera
- Per-printer enable toggle, URL input, type selector, test button
- Toast notification on save
Closes#143