Files
bambuddy/backend/tests/unit/test_finish_photo_from_timelapse.py
T
maziggy 12d17bfbe7 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.
2026-06-05 13:53:26 +02:00

175 lines
6.6 KiB
Python

"""Tests for _capture_finish_photo_from_timelapse (#1397).
The polling helper runs in parallel with _scan_for_timelapse_with_retries —
it waits for archive.timelapse_path to land in the DB, then extracts the
last frame as the finish photo. These tests exercise the four shapes the
helper has to handle correctly:
1. timelapse never lands within timeout → return None (caller falls back)
2. timelapse lands, extraction succeeds → return filename
3. timelapse lands, extraction fails → return None (caller falls back)
4. timelapse_path is set but the file doesn't exist on disk → keep polling
DB access is patched at the session-maker boundary so these tests run in
~50ms each without standing up a real engine.
"""
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app import main as main_module
from backend.app.main import _capture_finish_photo_from_timelapse
@asynccontextmanager
async def _fake_session(archive):
"""A fake session whose execute().scalar_one_or_none() returns `archive`.
`archive` is mutated by the test mid-poll to simulate the real flow:
the timelapse-attach background task setting `timelapse_path` after a
few poll cycles.
"""
result = SimpleNamespace(scalar_one_or_none=lambda: archive)
session = SimpleNamespace(execute=AsyncMock(return_value=result))
yield session
@pytest.fixture
def fake_archive():
"""Mutable archive stand-in. Tests flip `.timelapse_path` to simulate
the timelapse-attach task writing to the DB."""
return SimpleNamespace(id=42, timelapse_path=None)
@pytest.fixture(autouse=True)
def _fast_poll(monkeypatch):
"""Shrink poll interval + timeout so tests don't sleep for real."""
monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.01)
monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 0.2)
@pytest.fixture
def patched_session(fake_archive, monkeypatch):
"""Patch main.async_session so the helper reads our fake archive."""
monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_archive))
return fake_archive
async def test_returns_none_when_timelapse_never_lands(tmp_path: Path, patched_session):
"""Print finished without a timelapse — bail after timeout so the caller
falls back to the live-camera grab."""
result = await _capture_finish_photo_from_timelapse(
archive_id=42,
archive_dir=tmp_path,
)
assert result is None
async def test_extracts_frame_when_timelapse_lands(tmp_path: Path, patched_session, monkeypatch):
"""Simulate the timelapse landing after one poll cycle and extraction
succeeding — should return a filename matching the finish_*.jpg pattern."""
# Lay down a stub timelapse file relative to base_dir so the path
# join works the way the helper expects.
monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
video_relpath = Path("archive/1/print/timelapse.mp4")
video_abspath = tmp_path / video_relpath
video_abspath.parent.mkdir(parents=True, exist_ok=True)
video_abspath.write_bytes(b"x" * 100) # non-empty so the size check passes
# Patch extraction to succeed unconditionally — the actual ffmpeg
# codepath has its own test file.
async def fake_extract(src, dst):
dst.write_bytes(b"\xff\xd8" + b"\x00" * 50) # JPEG SOI
return True
monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0)
# Flip the archive into the "timelapse landed" state before the first
# poll — the helper picks it up on its initial read.
patched_session.timelapse_path = str(video_relpath)
with patch(
"backend.app.services.camera.extract_video_last_frame",
new=fake_extract,
):
result = await _capture_finish_photo_from_timelapse(
archive_id=42,
archive_dir=tmp_path / "archive_dir",
)
assert result is not None
assert result.startswith("finish_")
assert result.endswith(".jpg")
assert (tmp_path / "archive_dir" / "photos" / result).exists()
async def test_returns_none_when_extraction_fails(tmp_path: Path, patched_session, monkeypatch):
"""Timelapse landed but ffmpeg said no — we don't keep retrying on the
same broken file; return None so the caller falls back."""
monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
video_relpath = Path("archive/1/print/timelapse.mp4")
video_abspath = tmp_path / video_relpath
video_abspath.parent.mkdir(parents=True, exist_ok=True)
video_abspath.write_bytes(b"x" * 100)
async def fake_extract_fails(src, dst):
return False
patched_session.timelapse_path = str(video_relpath)
with patch(
"backend.app.services.camera.extract_video_last_frame",
new=fake_extract_fails,
):
result = await _capture_finish_photo_from_timelapse(
archive_id=42,
archive_dir=tmp_path / "archive_dir",
)
assert result is None
async def test_polls_until_file_appears(tmp_path: Path, patched_session, monkeypatch):
"""timelapse_path is set, but the file isn't on disk yet (the attach
background task hasn't finished writing). Should keep polling — and
succeed once the file materialises."""
monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.05)
monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 1.0)
video_relpath = Path("archive/1/print/timelapse.mp4")
patched_session.timelapse_path = str(video_relpath)
# File not present yet. Schedule it to land after ~150ms.
import asyncio
async def materialise_later():
await asyncio.sleep(0.15)
video_abspath = tmp_path / video_relpath
video_abspath.parent.mkdir(parents=True, exist_ok=True)
video_abspath.write_bytes(b"x" * 100)
async def fake_extract(src, dst):
dst.write_bytes(b"\xff\xd8")
return True
materialise = asyncio.create_task(materialise_later())
try:
with patch(
"backend.app.services.camera.extract_video_last_frame",
new=fake_extract,
):
result = await _capture_finish_photo_from_timelapse(
archive_id=42,
archive_dir=tmp_path / "archive_dir",
)
finally:
materialise.cancel()
assert result is not None
assert result.startswith("finish_")