From 12d17bfbe7402b52823bdd75f2b3041f56bcb3db Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 5 Jun 2026 13:53:26 +0200 Subject: [PATCH] fix(photo): source finish photo from forced timelapse + cleanup (#1397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's live-camera grab captured the bed already dropped, ruining the photo framing. Source the photo from a brief Bambu timelapse instead — firmware stops timelapse recording AFTER toolhead parks but BEFORE bed-drop runs, so the last frame frames the finished print correctly. When capture_finish_photo is on AND the user did not opt in to timelapse for this print, force timelapse=True at dispatch + mark the new PrintArchive.bambuddy_forced_timelapse column. After extraction (success or failure), cleanup deletes the locally-attached file, clears archive.timelapse_path, and walks the four scanner directories (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE against the original filename. User-opted-in timelapses pass through unchanged. Resolver lives at services/background_dispatch.py::resolve_effective_timelapse (module-level so the print queue can reuse it). Both dispatch paths wired: background_dispatch.py (Print Now / Reprint) AND print_scheduler.py:_start_print (the queue). Field testing caught the scheduler gap on the first round — AST regression test now asserts start_print(timelapse=...) references effective_timelapse, not the raw item.timelapse, so a future refactor can't silently drop it. Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded frames overwrite the same output file, so the file left on disk is the literal last frame regardless of duration. Bambu records one frame per layer-change, so a 16-layer cube produces a 0.6 s timelapse — the original -sseof -1.0 approach seeked before the start of the file and returned frame 0 (empty bed). Decoding every frame is fine; Bambu timelapses are short by construction even on hours-long prints. Migration adds bambuddy_forced_timelapse branched on is_sqlite() (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN). Verified live on postgres:16-alpine. Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active so the notification carries the bed-up photo instead of falling back to the live-cam grab on slow links. Scope limit, documented in the camera wiki: prints started directly on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass both dispatch paths, so the override doesn't fire there. Future option: mid-print M981 S1 P20000 MQTT toggle in on_print_start. Setting description rewritten in all 11 locales to drop the "only works when timelapse enabled" caveat (Bambuddy now forces it) and explain the kept-or-deleted behaviour. --- CHANGELOG.md | 3 + backend/app/core/database.py | 13 + backend/app/main.py | 284 +++++++++++++++--- backend/app/models/archive.py | 6 + backend/app/schemas/settings.py | 8 +- backend/app/services/background_dispatch.py | 56 +++- backend/app/services/camera.py | 78 +++++ backend/app/services/print_scheduler.py | 18 +- .../unit/services/test_background_dispatch.py | 7 +- .../test_background_dispatch_watchdog.py | 16 + .../services/test_dispatch_force_timelapse.py | 156 ++++++++++ .../unit/test_cleanup_forced_timelapse.py | 205 +++++++++++++ .../unit/test_extract_video_last_frame.py | 171 +++++++++++ .../unit/test_finish_photo_from_timelapse.py | 174 +++++++++++ .../test_scheduler_force_timelapse_wiring.py | 71 +++++ frontend/src/i18n/locales/de.ts | 2 +- frontend/src/i18n/locales/en.ts | 2 +- frontend/src/i18n/locales/es.ts | 2 +- frontend/src/i18n/locales/fr.ts | 2 +- frontend/src/i18n/locales/it.ts | 2 +- frontend/src/i18n/locales/ja.ts | 2 +- frontend/src/i18n/locales/ko.ts | 2 +- frontend/src/i18n/locales/pt-BR.ts | 2 +- frontend/src/i18n/locales/tr.ts | 2 +- frontend/src/i18n/locales/zh-CN.ts | 2 +- frontend/src/i18n/locales/zh-TW.ts | 2 +- .../{index-Dai0-twV.js => index-14DWwfbR.js} | 22 +- static/index.html | 2 +- 28 files changed, 1238 insertions(+), 74 deletions(-) create mode 100644 backend/tests/unit/services/test_dispatch_force_timelapse.py create mode 100644 backend/tests/unit/test_cleanup_forced_timelapse.py create mode 100644 backend/tests/unit/test_extract_video_last_frame.py create mode 100644 backend/tests/unit/test_finish_photo_from_timelapse.py create mode 100644 backend/tests/unit/test_scheduler_force_timelapse_wiring.py rename static/assets/{index-Dai0-twV.js => index-14DWwfbR.js} (87%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a45ab8bfd..a255d323a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased +### Fixed +- **Finish photo no longer shows the bed already dropped (#1397, reported by @rtadams89, @Jeff-GebhartCA, @MA2ZAK)** — Bambu's end-gcode lowers the build plate as soon as the print completes. Bambuddy's existing finish-photo path captured a fresh camera frame at `gcode_state=FINISH`, by which time the bed was already at the bottom of the chamber — the photo showed the top of the print well below the camera's natural framing, badly framed and sometimes invisible. Earlier capture attempts (at `layer_num >= total_layer_num` while still RUNNING) hit motion-blur because the toolhead was still parking; capturing through the window kept the wrong frame because the latest was always ~2s before FINISH, mid-bed-drop. **The fix sources the photo from a brief Bambu timelapse Bambuddy records on every dispatched print instead.** Firmware stops timelapse recording AFTER the toolhead parks but BEFORE the bed-drop end-gcode runs, so the last frame frames the finished print correctly — verified on N=2 H2C prints by extracting the last frame of two real timelapses (`spoolbuddy_v2.1` and `case_SpoolBuddy`); both showed the print clearly with the toolhead parked off-frame upper-left and the bed at print height, no motion blur. The post-park-pre-drop window is at least ~2 seconds wide on both, so `-sseof -1.0` (seek to last second, skip the literal last frame) is safe against any encoder tail artifact. **Implementation: force-on at dispatch + cleanup after extraction.** `BackgroundDispatchService._resolve_effective_timelapse(db, archive, job)` reads the `capture_finish_photo` setting before each `start_print` call (reprint + library-file flows both wired) and, when the user did NOT opt in to timelapse for this print, overrides `timelapse=True` on the MQTT command + marks the new `PrintArchive.bambuddy_forced_timelapse` column True. User-opted-in timelapses pass through unchanged (no override needed). Migration adds the column branched on `is_sqlite()` for the boolean default (`DEFAULT 0` on SQLite, `DEFAULT FALSE` on Postgres — PG rejects `DEFAULT 0` for BOOLEAN). New module-level `extract_video_last_frame(video_path, output_path)` in `services/camera.py` runs a single `ffmpeg -sseof -1.0 -i