6 Commits
Author SHA1 Message Date
maziggy 72a8dafde9 y fix(camera): rotate every still exactly once, and cover the sources that let ffmpeg write the file
Review follow-ups on applying camera_rotation to finish photos and
    layer-timelapse frames.

    Rotating the frame popped from _stage22_finish_frames rotated one of its
    sources twice. The cache has two kinds of feeder: live grabs, which are raw,
    and the #1867 in-print bank, whose bytes come from
    _capture_snapshot_for_notification and have already been rotated on the way
    in. The consumer cannot tell them apart, so on the finish_state trigger - the
    path the bank exists to serve, on firmware that never emits stg_cur=22 - a 180
    degree rotation cancelled itself out and the photo was upside-down again,
    which is the reported symptom exactly; 90 and 270 landed 180 out. Rotation now
    happens where each frame is captured, so every entry in the cache carries one
    rotation whatever produced it, and the invariant is stated both where the
    cache is declared and where it is consumed.

    Two finish-photo sources were still writing unrotated files: the built-in
    camera's own capture_finish_photo, and the still extracted from a
    printer-recorded timelapse - which is the *preferred* source for a built-in
    camera print, so a user with a rotation set got a correctly oriented photo or
    not depending on which source happened to win. Neither ever holds the frame as
    bytes; ffmpeg writes the file and they return a filename. apply_camera_rotation_to_file
    handles that case and is best-effort - a failed rotate leaves the unrotated
    file rather than losing a delivered photo. The archived video itself is the
    printer's own file and is not re-encoded, so it still plays at the camera's
    native orientation; the CHANGELOG says so rather than leaving it to be
    discovered.

    apply_camera_rotation logs at debug, not info. It was on a path that runs once
    per layer, where a tall print would have put hundreds of lines in the log for
    something the surrounding capture already reports at debug.

    The moved rotation logic had no test of its own - every existing test patches
    it out and asserts the call, so a flipped sign or a dropped expand=True would
    have shipped green. test_camera_rotation.py drives the real round trip: a
    corner marker pins which way it turns, the dimensions pin that the frame is
    not cropped, and an undecodable frame comes back by identity because a capture
    path must not lose a frame to a failed rotate.

    Tests for the fix itself sit on both sides of the cache. The producer half is
    driven directly; the consumer half is a closure nested inside on_print_complete
    with nothing able to reach it, so it is pinned by an AST guard - checked
    against the source because the alternative is no check at all. Reverting
    main.py to the pre-fix shape fails three of the five, the guard among them.

    The three new tests used Path("/tmp/test") for a patched base_dir, which Bandit
    flagged (B108); they take tmp_path now.
2026-08-02 09:47:29 +02:00
maziggy 432e956eff fix(camera): rotate every still exactly once, and cover the sources that let ffmpeg write the file
Review follow-ups on applying camera_rotation to finish photos and
layer-timelapse frames.

Rotating the frame popped from _stage22_finish_frames rotated one of its
sources twice. The cache has two kinds of feeder: live grabs, which are raw,
and the #1867 in-print bank, whose bytes come from
_capture_snapshot_for_notification and have already been rotated on the way
in. The consumer cannot tell them apart, so on the finish_state trigger - the
path the bank exists to serve, on firmware that never emits stg_cur=22 - a 180
degree rotation cancelled itself out and the photo was upside-down again,
which is the reported symptom exactly; 90 and 270 landed 180 out. Rotation now
happens where each frame is captured, so every entry in the cache carries one
rotation whatever produced it, and the invariant is stated both where the
cache is declared and where it is consumed.

Two finish-photo sources were still writing unrotated files: the built-in
camera's own capture_finish_photo, and the still extracted from a
printer-recorded timelapse - which is the *preferred* source for a built-in
camera print, so a user with a rotation set got a correctly oriented photo or
not depending on which source happened to win. Neither ever holds the frame as
bytes; ffmpeg writes the file and they return a filename. apply_camera_rotation_to_file
handles that case and is best-effort - a failed rotate leaves the unrotated
file rather than losing a delivered photo. The archived video itself is the
printer's own file and is not re-encoded, so it still plays at the camera's
native orientation; the CHANGELOG says so rather than leaving it to be
discovered.

apply_camera_rotation logs at debug, not info. It was on a path that runs once
per layer, where a tall print would have put hundreds of lines in the log for
something the surrounding capture already reports at debug.

The moved rotation logic had no test of its own - every existing test patches
it out and asserts the call, so a flipped sign or a dropped expand=True would
have shipped green. test_camera_rotation.py drives the real round trip: a
corner marker pins which way it turns, the dimensions pin that the frame is
not cropped, and an undecodable frame comes back by identity because a capture
path must not lose a frame to a failed rotate.

Tests for the fix itself sit on both sides of the cache. The producer half is
driven directly; the consumer half is a closure nested inside on_print_complete
with nothing able to reach it, so it is pinned by an AST guard - checked
against the source because the alternative is no check at all. Reverting
main.py to the pre-fix shape fails three of the five, the guard among them.

The three new tests used Path("/tmp/test") for a patched base_dir, which Bandit
flagged (B108); they take tmp_path now.
2026-07-31 09:30:06 +02:00
maziggy 08c9ec6749 fix(camera): protect an in-progress stitch from the orphan sweep, and only sweep this feature's own files
Review follow-ups on the orphaned timelapse session cleanup.

The sweep's own docstring said min_age_seconds made it safe to call mid-run.
It did not. on_print_complete drops the session from _active_sessions before
handing frames_dir to ffmpeg, so for the length of a stitch the directory
matches no active session, and its mtime is the last layer's frame write -
which on a tall print's final layer is easily older than the margin. The
default margin is 300s and the stitch timeout is also 300s, so the two were
tied with no headroom at all: a sweep landing in that window deleted ffmpeg's
input from under it. _finalizing_sessions now covers the stitch, set as the
session leaves _active_sessions and cleared in a finally so a failed stitch
cannot leak the marker and make that printer's leftovers permanently
un-sweepable. The docstring names all three guards and which gap each covers,
including that the margin does have real headroom for the two cases it suits -
a session mid-creation, and the freshly written .mp4 awaiting attach.

The file branch now requires the timelapse_<session_id>.mp4 shape its own
comment describes. It previously deleted any file under
timelapse_frames/<printer_id>/ past the margin; nothing else writes there
today, but age alone is not a reason to delete a file this feature did not
create.

Dropped ignore_errors=True from the rmtree. It made the surrounding
except OSError unreachable, so a read-only mount or a permissions problem was
counted and logged as a successful removal - and that log is the only evidence
an operator has of what was deleted.

Tests 5 -> 9: sparing a session mid-stitch, the finalizing marker cleared even
when the stitch raises, unrelated files left alone, and a failed removal not
counted. The failure test's rmtree stub honours the real contract and returns
silently when ignore_errors=True, because that silent no-op is exactly what the
old call could never observe; a stub that raised unconditionally would have
passed against both versions and proved nothing.

main.py is unchanged: it has no module-level logger, and the inline
logging.getLogger(__name__) the sweep uses is the idiom throughout lifespan.
2026-07-31 09:02:58 +02:00
CarlandClaude Sonnet 5 5db4c75ca0 fix(camera): apply camera_rotation to layer-timelapse frames too
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>
2026-07-30 17:34:13 +01:00
CarlandClaude Sonnet 5 24863203fb fix(camera): sweep orphaned timelapse session directories on startup
_active_sessions is in-memory only, so a process restart mid-print
loses track of any active layer-timelapse session without ever calling
cancel_session()/cleanup() - the frames directory (and, if stitching
had already produced output before the restart, a stray
timelapse_<session_id>.mp4) are then orphaned on disk permanently, with
no equivalent to the ffmpeg orphan janitor to reap them.

Confirmed live: 38MB of exactly this leftover on the OrangePi after
several restarts during this week's testing, including two corrupt
48-byte .mp4s from stitches that got interrupted mid-write.

Adds cleanup_orphaned_timelapse_sessions(), run once at startup: for
each printer_id under timelapse_frames/, remove any frame directory or
stitched-output file that doesn't match that printer's current active
session (if any) and is older than a defensive margin (5 min default).
A restart-recovered print never gets a new timelapse session either
(#1353's _maybe_start_layer_timelapse only fires on fresh PRINT_START
events), so nothing orphaned here can ever be resumed - safe to always
remove once it's old enough not to be a startup race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:25:25 +01:00
maziggy 691fb133b7 Add external network camera support for printers
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
2026-01-24 09:58:45 +01:00