mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user