mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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.
175 lines
6.6 KiB
Python
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_")
|