mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +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.
172 lines
5.9 KiB
Python
172 lines
5.9 KiB
Python
"""Tests for extract_video_last_frame (#1397).
|
|
|
|
Sources the finish photo from the per-print Bambu timelapse's last frame —
|
|
captured by firmware after the toolhead parks but before the bed-drop
|
|
end-gcode runs, so the print is framed correctly. A live camera grab at
|
|
gcode_state=FINISH would capture the bed already lowered.
|
|
|
|
We can't ship a real Bambu timelapse fixture in the repo (~7-11 MB each),
|
|
so the happy-path test builds a tiny synthetic MP4 with ffmpeg at runtime.
|
|
Failure paths (missing ffmpeg, missing source, subprocess failure, timeout)
|
|
are exercised with monkeypatching so the suite stays hermetic and fast.
|
|
"""
|
|
|
|
import asyncio
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.camera import extract_video_last_frame
|
|
|
|
_HAS_FFMPEG = shutil.which("ffmpeg") is not None
|
|
|
|
|
|
def _make_synthetic_mp4(dest: Path, duration_seconds: float = 1.0) -> None:
|
|
"""Create a tiny test MP4 via ffmpeg's testsrc generator.
|
|
|
|
Smallest valid MP4 we can construct without committing binary fixtures —
|
|
one second of 32x32 testsrc, ultrafast encode, ~3-5 KB.
|
|
"""
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
f"testsrc=duration={duration_seconds}:size=32x32:rate=10",
|
|
"-preset",
|
|
"ultrafast",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
str(dest),
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, check=False)
|
|
if result.returncode != 0:
|
|
pytest.fail(f"ffmpeg fixture build failed (exit {result.returncode}): {result.stderr.decode()[:300]}")
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg not on PATH")
|
|
async def test_extracts_jpeg_from_real_mp4(tmp_path: Path):
|
|
src = tmp_path / "synthetic.mp4"
|
|
_make_synthetic_mp4(src)
|
|
out = tmp_path / "out.jpg"
|
|
|
|
ok = await extract_video_last_frame(src, out)
|
|
|
|
assert ok is True
|
|
assert out.exists()
|
|
assert out.stat().st_size > 0
|
|
# JPEG starts with the SOI marker (FFD8). Lightweight sanity check —
|
|
# we'd otherwise depend on Pillow just to decode.
|
|
assert out.read_bytes()[:2] == b"\xff\xd8"
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg not on PATH")
|
|
async def test_extracts_correctly_from_sub_second_video(tmp_path: Path):
|
|
"""Regression for #1397 round 1: small prints (few layers) produce
|
|
sub-second Bambu timelapses (~0.6s / 16 frames). The earlier
|
|
``-sseof -1.0`` approach seeked 1 second before end → before the
|
|
start of the file → ffmpeg silently returned frame 0. Verify the
|
|
write-every-frame-overwrite approach grabs a real frame regardless
|
|
of duration."""
|
|
src = tmp_path / "short.mp4"
|
|
_make_synthetic_mp4(src, duration_seconds=0.5) # 5 frames at 10fps
|
|
out = tmp_path / "out.jpg"
|
|
|
|
ok = await extract_video_last_frame(src, out)
|
|
|
|
assert ok is True
|
|
assert out.exists()
|
|
assert out.stat().st_size > 0
|
|
assert out.read_bytes()[:2] == b"\xff\xd8"
|
|
|
|
|
|
async def test_returns_false_when_source_missing(tmp_path: Path):
|
|
src = tmp_path / "does_not_exist.mp4"
|
|
out = tmp_path / "out.jpg"
|
|
|
|
ok = await extract_video_last_frame(src, out)
|
|
|
|
assert ok is False
|
|
assert not out.exists()
|
|
|
|
|
|
async def test_returns_false_when_source_empty(tmp_path: Path):
|
|
src = tmp_path / "empty.mp4"
|
|
src.touch()
|
|
out = tmp_path / "out.jpg"
|
|
|
|
ok = await extract_video_last_frame(src, out)
|
|
|
|
assert ok is False
|
|
assert not out.exists()
|
|
|
|
|
|
async def test_returns_false_when_ffmpeg_unavailable(tmp_path: Path):
|
|
src = tmp_path / "any.mp4"
|
|
src.write_bytes(b"\x00" * 100)
|
|
out = tmp_path / "out.jpg"
|
|
|
|
# Force the lookup path to return None — same shape as a host without
|
|
# ffmpeg installed. We don't want to be skipped on CI here; the
|
|
# not-installed path is a real production fallback and must be tested.
|
|
with patch("backend.app.services.camera.get_ffmpeg_path", return_value=None):
|
|
ok = await extract_video_last_frame(src, out)
|
|
|
|
assert ok is False
|
|
assert not out.exists()
|
|
|
|
|
|
async def test_returns_false_when_ffmpeg_exits_nonzero(tmp_path: Path):
|
|
"""ffmpeg failures (corrupt file, codec issue, etc.) return False, not
|
|
raise. The caller falls through to the existing live-camera path."""
|
|
src = tmp_path / "garbage.mp4"
|
|
src.write_bytes(b"not actually an mp4" * 100)
|
|
out = tmp_path / "out.jpg"
|
|
|
|
# Use a real ffmpeg invocation on garbage — guaranteed to fail with a
|
|
# non-zero exit code without us monkey-patching subprocess.
|
|
if not _HAS_FFMPEG:
|
|
pytest.skip("ffmpeg not on PATH; cannot exercise real failure path")
|
|
|
|
ok = await extract_video_last_frame(src, out)
|
|
|
|
assert ok is False
|
|
# ffmpeg may briefly touch the output file before failing; we don't
|
|
# require the file to be absent, only that the function reported failure
|
|
# so the caller falls back.
|
|
|
|
|
|
async def test_returns_false_on_subprocess_timeout(tmp_path: Path, monkeypatch):
|
|
"""A hung ffmpeg (network FS, bad codec, kernel bug) must not block the
|
|
finish-photo task forever. Patch ffmpeg to a sleep command that never
|
|
finishes — confirms the timeout path kills the subprocess."""
|
|
src = tmp_path / "stub.mp4"
|
|
src.write_bytes(b"\x00" * 100)
|
|
out = tmp_path / "out.jpg"
|
|
|
|
sleep_path = shutil.which("sleep")
|
|
if not sleep_path:
|
|
pytest.skip("sleep binary not available")
|
|
|
|
# Point get_ffmpeg_path at a real binary that never exits in 15s.
|
|
monkeypatch.setattr("backend.app.services.camera.get_ffmpeg_path", lambda: sleep_path)
|
|
# Tighten the timeout via monkeypatch on asyncio.wait_for to keep the
|
|
# test fast — patch only inside the call so we don't affect the harness.
|
|
real_wait_for = asyncio.wait_for
|
|
|
|
async def short_wait_for(awaitable, timeout):
|
|
return await real_wait_for(awaitable, timeout=0.5)
|
|
|
|
monkeypatch.setattr("backend.app.services.camera.asyncio.wait_for", short_wait_for)
|
|
|
|
ok = await extract_video_last_frame(src, out)
|
|
|
|
assert ok is False
|