capture_finish_photo (default-on) was forcing the timelapse MQTT field to
true on every print, even when the user explicitly unchecked Timelapse in
the slicer send dialog. On profiles with Timelapse Type = Smooth, that
flipped the printer's timelapse_record_flag and un-gated the per-layer
M622 J1 wipe blocks the slicer had baked in — toolhead parked off the
part every layer, on prints the user opted out of recording.
Root cause: #1397 implemented the finish-photo feature as a side channel
of "force the printer into timelapse-recording mode at dispatch" so the
last-frame extractor had a video to pull from. That conflated recording a
timelapse with snapping a finish photo, and the per-layer side effects
were decided at slice time by the user's timelapse_type, which Bambuddy
has no visibility into post-slice.
Fix: replace the force-on with a clean MQTT-state-driven trigger.
bambu_mqtt.py fires a new on_finish_photo_moment callback when
stg_cur transitions INTO 22 ("Filament unloading") while
_was_running AND end-of-print gate matches (progress >= 99 OR
layer_num >= total_layers OR remaining_time <= 0). The gate
disambiguates from mid-print color swaps (which also transit
stage 22 but at progress < 99). FINISH-state fallback in the same
handler fires the callback at the existing transition if stage 22
never arrived (cancel, external-spool-only, HMS halt, firmware
variants).
main.py registers on_finish_photo_moment as a top-level handler.
It pre-captures one camera frame at the trigger edge (external cam
→ buffered RTSP → fresh RTSP via capture_camera_frame_bytes) and
caches the JPEG bytes in _stage22_finish_frames[printer_id].
_background_finish_photo consumes the cached bytes before its
existing live-grab chain, so the saved photo has the better
framing (toolhead parked, before bed drop) without restructuring
the archive-resolution / fallback / notification wiring.
When a timelapse IS actively recording (user explicitly opted in),
pre-capture is skipped — _capture_finish_photo_from_timelapse
still extracts the last frame, which is still the best framing
and now has no force-on side effects because the user wanted the
video.
Removed: resolve_effective_timelapse, _resolve_effective_timelapse
wrapper, both background_dispatch call sites, the print_scheduler call
site, the archive.bambuddy_forced_timelapse write, _cleanup_forced_timelapse
(~75 lines including the FTP-DELE walk across /timelapse, /timelapse/video,
/record, /recording) and its call site. All paths now read
bool(item.timelapse) / bool(job.options.get("timelapse", False)) directly.
The archive.bambuddy_forced_timelapse DB column stays defined (default
False) for back-compat with existing rows — no consumer reads it anymore.