test_timelapse_jobs.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. """Timelapse editor re-encodes run as background jobs that report progress and can be cancelled."""
  2. import asyncio
  3. import shutil
  4. import stat
  5. import sys
  6. from pathlib import Path
  7. import pytest
  8. from backend.app.services import timelapse_jobs
  9. from backend.app.services.timelapse_processor import TimelapseProcessor, _communicate
  10. @pytest.fixture(autouse=True)
  11. def _no_jobs():
  12. timelapse_jobs._jobs.clear()
  13. yield
  14. timelapse_jobs._jobs.clear()
  15. async def _finished(job: timelapse_jobs.TimelapseJob) -> None:
  16. assert job.task is not None
  17. await asyncio.wait_for(asyncio.shield(job.task), 5)
  18. class TestJobs:
  19. @pytest.mark.asyncio
  20. async def test_a_finished_job_keeps_its_result(self):
  21. async def run(job):
  22. job.set_progress(0.5)
  23. return "archive/1/out.mp4", "Saved as out.mp4"
  24. job = timelapse_jobs.start_job(1, run)
  25. assert job.status == timelapse_jobs.RUNNING
  26. await _finished(job)
  27. assert timelapse_jobs.get_job(1) is job
  28. assert (job.status, job.progress, job.output_path, job.message) == (
  29. timelapse_jobs.COMPLETED,
  30. 1.0,
  31. "archive/1/out.mp4",
  32. "Saved as out.mp4",
  33. )
  34. @pytest.mark.asyncio
  35. async def test_a_failed_job_says_why(self):
  36. async def run(job):
  37. raise RuntimeError("Video processing failed")
  38. job = timelapse_jobs.start_job(1, run)
  39. await _finished(job)
  40. assert (job.status, job.error) == (timelapse_jobs.FAILED, "Video processing failed")
  41. @pytest.mark.asyncio
  42. async def test_one_job_per_archive_at_a_time(self):
  43. release = asyncio.Event()
  44. async def run(job):
  45. await release.wait()
  46. return "a", "b"
  47. job = timelapse_jobs.start_job(1, run)
  48. with pytest.raises(timelapse_jobs.JobAlreadyRunning):
  49. timelapse_jobs.start_job(1, run)
  50. other = timelapse_jobs.start_job(2, run)
  51. release.set()
  52. await _finished(job)
  53. await _finished(other)
  54. again = timelapse_jobs.start_job(1, run)
  55. await _finished(again)
  56. assert again.status == timelapse_jobs.COMPLETED
  57. @pytest.mark.asyncio
  58. async def test_cancelling_stops_the_job(self):
  59. started = asyncio.Event()
  60. stopped = []
  61. async def run(job):
  62. started.set()
  63. try:
  64. await asyncio.sleep(30)
  65. except asyncio.CancelledError:
  66. stopped.append(True)
  67. raise
  68. return "a", "b"
  69. job = timelapse_jobs.start_job(1, run)
  70. await started.wait()
  71. assert await timelapse_jobs.cancel_job(1) is True
  72. assert job.status == timelapse_jobs.CANCELLED
  73. assert stopped == [True]
  74. assert await timelapse_jobs.cancel_job(1) is False
  75. @pytest.mark.asyncio
  76. async def test_shutdown_cancels_every_running_job(self):
  77. async def run(job):
  78. await asyncio.sleep(30)
  79. return "a", "b"
  80. jobs = [timelapse_jobs.start_job(i, run) for i in (1, 2)]
  81. await asyncio.sleep(0)
  82. await timelapse_jobs.cancel_all()
  83. assert [j.status for j in jobs] == [timelapse_jobs.CANCELLED] * 2
  84. @pytest.mark.asyncio
  85. async def test_no_job_to_cancel(self):
  86. assert await timelapse_jobs.cancel_job(7) is False
  87. def _fake_ffmpeg(tmp_path: Path, body: str) -> str:
  88. """A stand-in ffmpeg: a Python script; the output path is its last argument."""
  89. script = tmp_path / "ffmpeg"
  90. script.write_text(f"#!{sys.executable}\nimport sys, time\nout = sys.argv[-1]\n{body}\n")
  91. script.chmod(script.stat().st_mode | stat.S_IEXEC)
  92. return str(script)
  93. def _processor(tmp_path: Path, ffmpeg: str) -> TimelapseProcessor:
  94. processor = TimelapseProcessor.__new__(TimelapseProcessor)
  95. processor.input_path = tmp_path / "in.mp4"
  96. processor.ffmpeg = ffmpeg
  97. processor.ffprobe = ffmpeg
  98. return processor
  99. class TestProcessor:
  100. @pytest.mark.asyncio
  101. async def test_progress_is_the_share_of_the_output_written(self, tmp_path):
  102. ffmpeg = _fake_ffmpeg(
  103. tmp_path,
  104. "open(out, 'wb').write(b'x')\n"
  105. "for us in ('N/A', '1000000', '2000000'):\n"
  106. " print(f'out_time_us={us}', flush=True)\n"
  107. " print('progress=continue', flush=True)",
  108. )
  109. seen: list[float] = []
  110. # 8 s of input at double speed: 4 s of output.
  111. ok = await _processor(tmp_path, ffmpeg).process(
  112. tmp_path / "out.mp4", trim_start=2, trim_end=10, speed=2.0, on_progress=seen.append
  113. )
  114. assert ok is True
  115. assert seen == [0.25, 0.5]
  116. @pytest.mark.asyncio
  117. async def test_cancelling_stops_ffmpeg_and_drops_the_partial_file(self, tmp_path):
  118. ffmpeg = _fake_ffmpeg(
  119. tmp_path,
  120. "open(out, 'wb').write(b'partial')\nprint('out_time_us=1000000', flush=True)\ntime.sleep(60)",
  121. )
  122. output = tmp_path / "out.mp4"
  123. reported = asyncio.Event()
  124. task = asyncio.create_task(
  125. _processor(tmp_path, ffmpeg).process(output, trim_end=10, on_progress=lambda _: reported.set())
  126. )
  127. await asyncio.wait_for(reported.wait(), 10)
  128. task.cancel()
  129. with pytest.raises(asyncio.CancelledError):
  130. await asyncio.wait_for(task, 5)
  131. assert not output.exists()
  132. @pytest.mark.asyncio
  133. async def test_a_probe_that_hangs_is_killed(self):
  134. sleep = shutil.which("sleep")
  135. if sleep is None:
  136. pytest.skip("no sleep binary")
  137. process = await asyncio.create_subprocess_exec(
  138. sleep, "30", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
  139. )
  140. # Killed, not waited out: well inside the 30 s the process would run.
  141. with pytest.raises(TimeoutError):
  142. await asyncio.wait_for(_communicate(process, 0.2), 5)
  143. assert process.returncode is not None
  144. @pytest.mark.asyncio
  145. async def test_without_a_known_length_it_encodes_without_progress(tmp_path):
  146. ffmpeg = _fake_ffmpeg(tmp_path, "open(out, 'wb').write(b'x')\nprint('out_time_us=1000000', flush=True)")
  147. processor = _processor(tmp_path, ffmpeg)
  148. processor.ffprobe = "/nonexistent/ffprobe"
  149. seen: list[float] = []
  150. ok = await processor.process(tmp_path / "out.mp4", on_progress=seen.append)
  151. assert ok is True
  152. assert seen == []