fix(photo): source finish photo from forced timelapse + cleanup (#1397)

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.
This commit is contained in:
maziggy
2026-06-05 13:53:26 +02:00
parent eb418a5b37
commit 12d17bfbe7
28 changed files with 1238 additions and 74 deletions
+13
View File
@@ -2040,6 +2040,19 @@ async def run_migrations(conn):
"CREATE INDEX IF NOT EXISTS ix_print_archives_deleted_at ON print_archives (deleted_at)",
)
# Migration: Add bambuddy_forced_timelapse to print_archives (#1397)
# Tracks prints where Bambuddy forced the firmware to record a timelapse
# so the finish-photo extractor could pull the post-park-pre-drop frame.
# The cleanup path uses this to delete the timelapse both locally and on
# the printer's SD after extraction — the user didn't opt in to a
# timelapse recording. Postgres rejects `DEFAULT 0` for BOOLEAN; SQLite
# accepts both 0/FALSE — branch the literal.
_bool_false_literal = "0" if is_sqlite() else "FALSE"
await _safe_execute(
conn,
f"ALTER TABLE print_archives ADD COLUMN bambuddy_forced_timelapse BOOLEAN DEFAULT {_bool_false_literal}",
)
# Migration: Create smart_plug_energy_snapshots table (#941)
# Hourly snapshots of each plug's lifetime counter, so date-range queries in
# "total consumption" energy mode can compute (last - first) deltas.