test_extract_video_last_frame.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """Tests for extract_video_last_frame (#1397).
  2. Sources the finish photo from the per-print Bambu timelapse's last frame —
  3. captured by firmware after the toolhead parks but before the bed-drop
  4. end-gcode runs, so the print is framed correctly. A live camera grab at
  5. gcode_state=FINISH would capture the bed already lowered.
  6. We can't ship a real Bambu timelapse fixture in the repo (~7-11 MB each),
  7. so the happy-path test builds a tiny synthetic MP4 with ffmpeg at runtime.
  8. Failure paths (missing ffmpeg, missing source, subprocess failure, timeout)
  9. are exercised with monkeypatching so the suite stays hermetic and fast.
  10. """
  11. import asyncio
  12. import shutil
  13. import subprocess
  14. from pathlib import Path
  15. from unittest.mock import patch
  16. import pytest
  17. from backend.app.services.camera import extract_video_last_frame
  18. _HAS_FFMPEG = shutil.which("ffmpeg") is not None
  19. def _make_synthetic_mp4(dest: Path, duration_seconds: float = 1.0) -> None:
  20. """Create a tiny test MP4 via ffmpeg's testsrc generator.
  21. Smallest valid MP4 we can construct without committing binary fixtures —
  22. one second of 32x32 testsrc, ultrafast encode, ~3-5 KB.
  23. """
  24. cmd = [
  25. "ffmpeg",
  26. "-y",
  27. "-hide_banner",
  28. "-loglevel",
  29. "error",
  30. "-f",
  31. "lavfi",
  32. "-i",
  33. f"testsrc=duration={duration_seconds}:size=32x32:rate=10",
  34. "-preset",
  35. "ultrafast",
  36. "-pix_fmt",
  37. "yuv420p",
  38. str(dest),
  39. ]
  40. result = subprocess.run(cmd, capture_output=True, check=False)
  41. if result.returncode != 0:
  42. pytest.fail(f"ffmpeg fixture build failed (exit {result.returncode}): {result.stderr.decode()[:300]}")
  43. @pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg not on PATH")
  44. async def test_extracts_jpeg_from_real_mp4(tmp_path: Path):
  45. src = tmp_path / "synthetic.mp4"
  46. _make_synthetic_mp4(src)
  47. out = tmp_path / "out.jpg"
  48. ok = await extract_video_last_frame(src, out)
  49. assert ok is True
  50. assert out.exists()
  51. assert out.stat().st_size > 0
  52. # JPEG starts with the SOI marker (FFD8). Lightweight sanity check —
  53. # we'd otherwise depend on Pillow just to decode.
  54. assert out.read_bytes()[:2] == b"\xff\xd8"
  55. @pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg not on PATH")
  56. async def test_extracts_correctly_from_sub_second_video(tmp_path: Path):
  57. """Regression for #1397 round 1: small prints (few layers) produce
  58. sub-second Bambu timelapses (~0.6s / 16 frames). The earlier
  59. ``-sseof -1.0`` approach seeked 1 second before end → before the
  60. start of the file → ffmpeg silently returned frame 0. Verify the
  61. write-every-frame-overwrite approach grabs a real frame regardless
  62. of duration."""
  63. src = tmp_path / "short.mp4"
  64. _make_synthetic_mp4(src, duration_seconds=0.5) # 5 frames at 10fps
  65. out = tmp_path / "out.jpg"
  66. ok = await extract_video_last_frame(src, out)
  67. assert ok is True
  68. assert out.exists()
  69. assert out.stat().st_size > 0
  70. assert out.read_bytes()[:2] == b"\xff\xd8"
  71. async def test_returns_false_when_source_missing(tmp_path: Path):
  72. src = tmp_path / "does_not_exist.mp4"
  73. out = tmp_path / "out.jpg"
  74. ok = await extract_video_last_frame(src, out)
  75. assert ok is False
  76. assert not out.exists()
  77. async def test_returns_false_when_source_empty(tmp_path: Path):
  78. src = tmp_path / "empty.mp4"
  79. src.touch()
  80. out = tmp_path / "out.jpg"
  81. ok = await extract_video_last_frame(src, out)
  82. assert ok is False
  83. assert not out.exists()
  84. async def test_returns_false_when_ffmpeg_unavailable(tmp_path: Path):
  85. src = tmp_path / "any.mp4"
  86. src.write_bytes(b"\x00" * 100)
  87. out = tmp_path / "out.jpg"
  88. # Force the lookup path to return None — same shape as a host without
  89. # ffmpeg installed. We don't want to be skipped on CI here; the
  90. # not-installed path is a real production fallback and must be tested.
  91. with patch("backend.app.services.camera.get_ffmpeg_path", return_value=None):
  92. ok = await extract_video_last_frame(src, out)
  93. assert ok is False
  94. assert not out.exists()
  95. async def test_returns_false_when_ffmpeg_exits_nonzero(tmp_path: Path):
  96. """ffmpeg failures (corrupt file, codec issue, etc.) return False, not
  97. raise. The caller falls through to the existing live-camera path."""
  98. src = tmp_path / "garbage.mp4"
  99. src.write_bytes(b"not actually an mp4" * 100)
  100. out = tmp_path / "out.jpg"
  101. # Use a real ffmpeg invocation on garbage — guaranteed to fail with a
  102. # non-zero exit code without us monkey-patching subprocess.
  103. if not _HAS_FFMPEG:
  104. pytest.skip("ffmpeg not on PATH; cannot exercise real failure path")
  105. ok = await extract_video_last_frame(src, out)
  106. assert ok is False
  107. # ffmpeg may briefly touch the output file before failing; we don't
  108. # require the file to be absent, only that the function reported failure
  109. # so the caller falls back.
  110. async def test_returns_false_on_subprocess_timeout(tmp_path: Path, monkeypatch):
  111. """A hung ffmpeg (network FS, bad codec, kernel bug) must not block the
  112. finish-photo task forever. Patch ffmpeg to a sleep command that never
  113. finishes — confirms the timeout path kills the subprocess."""
  114. src = tmp_path / "stub.mp4"
  115. src.write_bytes(b"\x00" * 100)
  116. out = tmp_path / "out.jpg"
  117. sleep_path = shutil.which("sleep")
  118. if not sleep_path:
  119. pytest.skip("sleep binary not available")
  120. # Point get_ffmpeg_path at a real binary that never exits in 15s.
  121. monkeypatch.setattr("backend.app.services.camera.get_ffmpeg_path", lambda: sleep_path)
  122. # Tighten the timeout via monkeypatch on asyncio.wait_for to keep the
  123. # test fast — patch only inside the call so we don't affect the harness.
  124. real_wait_for = asyncio.wait_for
  125. async def short_wait_for(awaitable, timeout):
  126. return await real_wait_for(awaitable, timeout=0.5)
  127. monkeypatch.setattr("backend.app.services.camera.asyncio.wait_for", short_wait_for)
  128. ok = await extract_video_last_frame(src, out)
  129. assert ok is False