mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Same gap as the finish-photo fix: camera_rotation was only ever wired into the notification-snapshot path, so a layer-timelapse video came out upside-down whenever a rotation was configured - every frame (fresh or reused from the live view's buffer) was written to disk raw. Extracts the rotation logic out of main.py into a shared apply_camera_rotation(image_data, rotation, logger) in services/camera.py (taking the rotation value directly rather than a printer object, so both main.py's printer-shaped callers and layer_timelapse's plain int field can use it). main.py's _apply_camera_rotation becomes a thin compatibility wrapper so its existing call sites are unchanged. Threads a `rotation` field through TimelapseSession/start_session, set from printer.camera_rotation in _maybe_start_layer_timelapse, and applies it (via asyncio.to_thread, since PIL rotation is CPU-bound) in capture_layer before each frame is written - after #2707's live_frame_for_capture() resolves the frame, regardless of whether it came fresh or from the live view's buffer. Rebased onto #2707's landed implementation: capture_layer now calls live_frame_for_capture() instead of the older is_stream_active/ try_get_active_buffered_frame pair this was originally written against, so the layer-timelapse tests are updated to match, and the now-redundant TestCaptureLayerAvoidsCompetingWithLiveViewer class (superseded by #2707's own test_external_camera_live_frame_reuse.py) is dropped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
111 lines
4.8 KiB
Python
111 lines
4.8 KiB
Python
"""Regression test for #1353: layer timelapse must start for queue/VP-dispatched prints.
|
|
|
|
Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
|
|
print queue (so each print landed in the on_print_start "expected archive"
|
|
branch). Frames were never captured, no MP4 was produced, yet the post-print
|
|
log line said "Stitching layer timelapse for printer 1" — because
|
|
`tl_complete()` ran, found no active session, and silently returned None.
|
|
|
|
Root cause: only the two new-archive code paths in on_print_start
|
|
(`fallback_archive` + `archive_print`) called `layer_timelapse.start_session`.
|
|
The expected-archive branch — where reprints and queue dispatch land —
|
|
updated the existing archive's status to "printing" but never started a
|
|
timelapse session.
|
|
|
|
Fix: the three start_session call sites in on_print_start were unified
|
|
behind `_maybe_start_layer_timelapse(printer, printer_id, archive_id)`,
|
|
which gates on the same `external_camera_enabled and external_camera_url`
|
|
check. Testing the helper directly (instead of driving the whole
|
|
on_print_start flow) keeps this regression locked in without dragging in
|
|
unrelated side effects (plate detection, DB queries, MQTT relay, etc.).
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from backend.app.main import _maybe_start_layer_timelapse
|
|
|
|
|
|
def _make_printer(*, external_camera_enabled: bool, external_camera_url: str | None):
|
|
"""Construct a minimal printer-shaped object with exactly the attributes
|
|
the helper reads. SimpleNamespace is used over MagicMock so attribute
|
|
access raises AttributeError on anything unexpected — keeps the test
|
|
honest about which fields the helper actually depends on.
|
|
"""
|
|
return SimpleNamespace(
|
|
external_camera_enabled=external_camera_enabled,
|
|
external_camera_url=external_camera_url,
|
|
external_camera_type="snapshot",
|
|
external_camera_snapshot_url=external_camera_url,
|
|
)
|
|
|
|
|
|
def test_starts_timelapse_when_external_camera_enabled():
|
|
"""Queue/VP-dispatched prints land in the expected-archive branch and must
|
|
start the timelapse session there (the #1353 root cause). The helper is
|
|
called from all three on_print_start paths (expected-archive promotion,
|
|
fallback archive, fresh archive) so testing it once covers all three."""
|
|
printer = _make_printer(
|
|
external_camera_enabled=True,
|
|
external_camera_url="http://camera.local:5000/snapshot.jpg",
|
|
)
|
|
|
|
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
|
|
started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=42)
|
|
|
|
assert started is True
|
|
mock_start_session.assert_called_once_with(
|
|
1,
|
|
42,
|
|
"http://camera.local:5000/snapshot.jpg",
|
|
"snapshot",
|
|
snapshot_url="http://camera.local:5000/snapshot.jpg",
|
|
rotation=0,
|
|
)
|
|
|
|
|
|
def test_skips_timelapse_when_external_camera_disabled():
|
|
"""The same guard that the new-archive paths use must hold here: no
|
|
external camera → no timelapse session. Otherwise we'd try to capture
|
|
from a None URL and crash the print-start flow."""
|
|
printer = _make_printer(external_camera_enabled=False, external_camera_url=None)
|
|
|
|
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
|
|
started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=99)
|
|
|
|
assert started is False
|
|
mock_start_session.assert_not_called()
|
|
|
|
|
|
def test_skips_timelapse_when_url_missing_even_if_flag_set():
|
|
"""If the toggle is on but the URL field is empty (legacy / half-configured
|
|
install), the guard must still hold — calling start_session with an empty
|
|
URL would crash downstream when the capture thread tries to fetch frames."""
|
|
printer = _make_printer(external_camera_enabled=True, external_camera_url=None)
|
|
|
|
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
|
|
started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=7)
|
|
|
|
assert started is False
|
|
mock_start_session.assert_not_called()
|
|
|
|
|
|
def test_camera_type_defaults_to_mjpeg_when_unset():
|
|
"""external_camera_type defaults to 'mjpeg' in start_session when the
|
|
printer column is None — pre-existing contract preserved by the helper."""
|
|
printer = SimpleNamespace(
|
|
external_camera_enabled=True,
|
|
external_camera_url="http://cam/feed",
|
|
external_camera_type=None,
|
|
external_camera_snapshot_url=None,
|
|
)
|
|
|
|
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
|
|
_maybe_start_layer_timelapse(printer, printer_id=2, archive_id=11)
|
|
|
|
assert mock_start_session.called
|
|
call_kwargs = mock_start_session.call_args.kwargs
|
|
call_args = mock_start_session.call_args.args
|
|
assert call_args[3] == "mjpeg"
|
|
assert call_kwargs["snapshot_url"] is None
|