| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171 |
- """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
|