Files
bambuddy/backend/tests/unit/test_extract_video_last_frame.py
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

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