fix(camera): reuse the live view's frame for external-camera captures (#2707)

On a printer with an external camera, watching the live view while a print
ran meant the layer timelapse recorded almost nothing and the finish photo
went out with no image. The reporter measured 0 of 87 layer captures on one
print and 0 of 105 on another, both watched throughout. A USB camera allows
one V4L2 handle, so a capture during a live view fails outright.

The built-in camera has had this rule since #1348 and #1271: reuse the
viewer's buffered frame rather than opening a second connection. It was
never extended to the external paths, and it could not have been -- the
buffer it depends on was only ever populated by the built-in paths.
generate_mjpeg_stream yields multipart-wrapped chunks, so the route layer
could not recover the JPEG, and a guarded caller would have found an empty
buffer and skipped every time.

So the stream now publishes each raw frame through a new on_frame callback
(parallel to on_process from #2675), and the six one-shot consumers reuse
it: layer timelapse, the finish-photo moment and its background fallback,
the notification snapshot, Obico polling, and the plate check. A viewer
attached with nothing buffered yet skips that one attempt rather than
competing -- kicking the viewer off is worse than missing a frame.

on_frame exceptions are logged and swallowed, like iter_subscriber's
on_unsubscribe: buffering is a side effect and must never be able to take
the live stream down with it. The external stream's teardown now releases
the buffered frame too, ownership-checked so a concurrent viewer of the
same printer keeps its own.

Two side effects on paths not touched here, both improvements: the snapshot
endpoint and the finish-photo fallback chain consult get_buffered_frame and
can now serve an external camera's live frame. plate_detection's docstring
already claimed this behaviour while implementing it only for the built-in
fallback; that drift is resolved.
This commit is contained in:
maziggy
2026-07-30 13:05:15 +02:00
parent 40e7b60e8c
commit d9da60dd8d
8 changed files with 457 additions and 28 deletions
+24 -4
View File
@@ -607,6 +607,7 @@ async def generate_mjpeg_stream(
fps: int = 10,
*,
on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
on_frame: Callable[[bytes], None] | None = None,
stop_event: asyncio.Event | None = None,
) -> AsyncGenerator[bytes, None]:
"""Generator yielding MJPEG frames for streaming.
@@ -622,6 +623,16 @@ async def generate_mjpeg_stream(
open (#2675). Without it the process is reachable only from this
generator's own ``finally``, which an abrupt client disconnect can
skip (same cancellation-timing class as #776).
on_frame: Called with each RAW frame, before it is wrapped for the wire,
so the route layer can publish it as the printer's buffered frame
(#2707). It has to be a callback: what this generator yields is
multipart-wrapped, so a consumer of the stream cannot recover the
JPEG, and until now nothing populated the buffer for external
cameras at all — leaving every one-shot consumer (layer timelapse,
finish photo, Obico, plate check) with nothing to reuse and no
option but to open a competing handle on a single-reader device.
Exceptions are logged and swallowed: buffering must never be able
to break the live stream.
stop_event: When set, the reconnect loops stop retrying — so an explicit
stop (which kills the current ffmpeg) doesn't immediately respawn a
new process and reacquire the device.
@@ -632,6 +643,15 @@ async def generate_mjpeg_stream(
frame_interval = 1.0 / max(fps, 1)
last_frame_time = 0.0
def _publish(frame: bytes) -> bytes:
"""Hand the raw frame to on_frame, then format it for the wire."""
if on_frame is not None:
try:
on_frame(frame)
except Exception:
logger.exception("on_frame callback raised")
return _format_mjpeg_frame(frame)
if camera_type == "mjpeg":
# Proxy MJPEG stream directly, with reconnect on timeout
max_retries = 3
@@ -642,7 +662,7 @@ async def generate_mjpeg_stream(
current_time = asyncio.get_event_loop().time()
if current_time - last_frame_time >= frame_interval:
last_frame_time = current_time
yield _format_mjpeg_frame(frame)
yield _publish(frame)
if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
break
logger.warning(
@@ -659,7 +679,7 @@ async def generate_mjpeg_stream(
frame_yielded = False
async for frame in _stream_rtsp(url, fps, on_process=on_process):
frame_yielded = True
yield _format_mjpeg_frame(frame)
yield _publish(frame)
if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
break
logger.warning(
@@ -672,7 +692,7 @@ async def generate_mjpeg_stream(
elif camera_type == "usb":
# Use ffmpeg to stream from USB camera
async for frame in _stream_usb(url, fps, on_process=on_process):
yield _format_mjpeg_frame(frame)
yield _publish(frame)
elif camera_type == "snapshot":
# Poll snapshot URL at interval
@@ -680,7 +700,7 @@ async def generate_mjpeg_stream(
try:
frame = await _capture_snapshot(url, timeout=10)
if frame:
yield _format_mjpeg_frame(frame)
yield _publish(frame)
await asyncio.sleep(frame_interval)
except asyncio.CancelledError:
break
+20 -1
View File
@@ -67,7 +67,26 @@ class TimelapseSession:
self.last_layer = layer_num
try:
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
# Reuse the live view's frame instead of opening a second handle on
# a single-reader device (#2707). Unguarded, a print watched from
# start to finish recorded zero successful layer captures, and the
# stitched video came out empty or badly truncated.
from backend.app.api.routes.camera import live_frame_for_capture
defer, buffered = live_frame_for_capture(self.printer_id)
if defer:
if not buffered:
# Viewer attached but nothing buffered yet: skip this layer
# rather than compete and kick them off (#1348).
logger.debug(
"Skipping layer %s for printer %s: viewer attached, no buffered frame yet",
layer_num,
self.printer_id,
)
return False
frame_data = buffered
else:
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
if frame_data:
frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
await asyncio.to_thread(frame_path.write_bytes, frame_data)
+15
View File
@@ -193,6 +193,21 @@ class ObicoDetectionService:
return None
if printer.external_camera_enabled and printer.external_camera_url:
# Same rule as the built-in branch below, which this used to skip:
# an external camera is single-reader too, so polling while a viewer
# is attached just fails (#2707).
from backend.app.api.routes.camera import live_frame_for_capture
defer, buffered = live_frame_for_capture(printer_id)
if defer:
if buffered:
return buffered
logger.info(
"Obico: viewer attached for printer %s but buffer empty; "
"skipping this poll to avoid competing camera handle (#2707)",
printer_id,
)
return None
return await capture_external_frame(
printer.external_camera_url,
printer.external_camera_type,
+20 -8
View File
@@ -604,16 +604,28 @@ async def capture_camera_image(
# Try external camera first if requested and available
if use_external and external_camera_url and external_camera_type:
try:
from backend.app.api.routes.camera import live_frame_for_capture
from backend.app.services.external_camera import capture_frame
image_data = await capture_frame(
external_camera_url,
external_camera_type,
snapshot_url=external_camera_snapshot_url,
)
if image_data:
camera_source = "external"
logger.debug("Captured frame from external camera for printer %s", printer_id)
# What this function's docstring already promised, but only the
# built-in fallback below delivered: an external camera is
# single-reader too, so capturing while a viewer watches fails
# (#2707).
defer, buffered = live_frame_for_capture(printer_id)
if defer:
if buffered:
image_data = buffered
camera_source = "external (buffered)"
logger.debug("Using buffered external frame for printer %s", printer_id)
else:
image_data = await capture_frame(
external_camera_url,
external_camera_type,
snapshot_url=external_camera_snapshot_url,
)
if image_data:
camera_source = "external"
logger.debug("Captured frame from external camera for printer %s", printer_id)
except Exception as e:
logger.warning("Failed to capture from external camera: %s", e)