Browse Source

fix(camera): layer-timelapse stitch has bounded memory; no ffmpeg outlives its capture (#3292)

- The stitch encodes with -threads 2 and rc-lookahead=10 (preset and
  crf unchanged) and scales frames above 1080p down by pixel count:
  measured 1080p 1239 -> 301 MiB, 4K 4745 -> 273 MiB on 30 cores.
  The timelapse editor re-encode uses the same X264_BOUNDED_ARGS.
- reap_ffmpeg() kills and waits for a still-running ffmpeg in the
  finally of the external RTSP and USB captures, the printer camera
  capture and the last-frame extraction; the stitch does the same.
  Before, only a timeout stopped ffmpeg.
- A stitch cut short removes its partial video; the kept-frames note
  gives the bounded command; the RTSP capture no longer hits an
  unbound process (baseline down by one).
maziggy 11 giờ trước cách đây
mục cha
commit
e12aa7765c

+ 0 - 8
.basedpyright/baseline.json

@@ -2485,14 +2485,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportPossiblyUnboundVariable",
-                "range": {
-                    "startColumn": 11,
-                    "endColumn": 18,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportPossiblyUnboundVariable",
                 "range": {

+ 3 - 0
CHANGELOG.md

@@ -174,6 +174,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Stitching a layer timelapse no longer needs gigabytes of memory (#3292, reported by @fma965)** — ffmpeg's x264 encoder picks its number of threads from the host's cores, not the container's CPU limit, so stitching 300 frames of 1080p peaked at 1.2 GiB on a 30-core host and 120 frames of 4K at 4.6 GiB. ffmpeg runs inside Bambuddy's container and counts against its memory limit, so a pod with a 2 GiB limit was killed the moment a long print finished, and its timelapse was lost.
+  - **Now:** the stitch uses two encoder threads and a shorter lookahead, with the same quality settings. Frames larger than 1080p are scaled down to it, keeping their shape. Measured: 1080p 1.2 GiB → 300 MiB, 4K 4.6 GiB → 270 MiB. On a host with many cores the stitch takes longer. Saving an edit in the timelapse editor encodes with the same limits.
+  - **No ffmpeg left behind:** the camera snapshots, the printer camera capture, the last frame taken from a timelapse and the stitch now stop their ffmpeg however they end. Before, only running out of time did, and an error or a cancelled capture left ffmpeg running and holding memory. A stitch that stops part-way no longer leaves a partial video behind.
 - **The timelapse player fits the screen with a portrait video (#3281, reported by @BurgerKerman)** — A timelapse from a camera turned to portrait with the External Camera **Rotation** setting came out taller than a landscape screen. The player grew with it, so the bottom of the video and the play, seek and speed controls were off screen and could not be reached.
   - **Now:** the player is never taller than the window, and the video is scaled down to leave room for the controls, with black bars at the sides. A landscape timelapse is only scaled down when the window is too short for it. In the timelapse editor the video takes at most a little over half the window's height.
   - **Translated:** the player's Edit, Download and Speed labels and the skip buttons' tooltips were always in English.

+ 6 - 8
backend/app/services/camera.py

@@ -22,6 +22,7 @@ from backend.app.utils.ffmpeg_output import (
     FFMPEG_RTSP_LOGLEVEL,
     NO_FFMPEG_OUTPUT,
     read_ffmpeg_log_until_open,
+    reap_ffmpeg,
     summarize_ffmpeg_stderr,
 )
 
@@ -813,8 +814,6 @@ async def _capture_camera_frame_bytes_uncoalesced(
                 state, log = await read_ffmpeg_log_until_open(process)
                 stdout, stderr = await process.communicate()
         except TimeoutError:
-            process.kill()
-            await process.wait()
             logger.error("Camera frame bytes capture timed out after %ss", timeout)
             return None
         stderr = log + stderr
@@ -839,6 +838,8 @@ async def _capture_camera_frame_bytes_uncoalesced(
         logger.exception("Camera frame bytes capture failed: %s", e)
         return None
     finally:
+        # Killed however the capture ends, not only on its deadline (#3292).
+        await reap_ffmpeg(process)
         if process is not None:
             _active_capture_pids.discard(process.pid)
         await close_tls_proxy(proxy_server)
@@ -910,16 +911,13 @@ async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
         return True
     except TimeoutError:
         logger.warning("ffmpeg timed out extracting last frame from %s", video_path)
-        if process is not None:
-            try:
-                process.kill()
-                await process.wait()
-            except ProcessLookupError:
-                pass  # Already exited
         return False
     except OSError as e:
         logger.warning("ffmpeg subprocess error for %s: %s", video_path, e)
         return False
+    finally:
+        # Killed however this ends, not only on its deadline (#3292).
+        await reap_ffmpeg(process)
 
 
 def apply_camera_rotation(image_data: bytes, rotation: int, logger: logging.Logger) -> bytes:

+ 7 - 5
backend/app/services/external_camera.py

@@ -28,6 +28,7 @@ from backend.app.utils.ffmpeg_output import (
     FFMPEG_RTSP_LOGLEVEL,
     NO_FFMPEG_OUTPUT,
     read_ffmpeg_log_until_open,
+    reap_ffmpeg,
     summarize_ffmpeg_stderr,
     watch_ffmpeg_log,
 )
@@ -548,14 +549,13 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
 
     except TimeoutError:
         logger.warning("USB frame capture timed out after %ss", timeout)
-        if process:
-            process.kill()
-            await process.wait()
         return None
     except OSError as e:
         logger.error("USB frame capture failed: %s", e)
         return None
     finally:
+        # Killed however the capture ends, not only on its deadline (#3292).
+        await reap_ffmpeg(process)
         if process is not None:
             _active_capture_pids.discard(process.pid)
 
@@ -757,6 +757,7 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
         "-",
     ]
 
+    process = None
     try:
         logger.debug("Running ffmpeg RTSP capture...")
         process = await asyncio.create_subprocess_exec(
@@ -791,13 +792,14 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
 
     except TimeoutError:
         logger.warning("RTSP frame capture timed out after %ss", timeout)
-        if process:
-            process.kill()
         return None
     except OSError as e:
         logger.error("RTSP frame capture failed: %s", e)
         return None
     finally:
+        # Killed and waited for however the capture ends, not only on its
+        # deadline (#3292).
+        await reap_ffmpeg(process)
         if proxy_server:
             await close_tls_proxy(proxy_server)
 

+ 25 - 8
backend/app/services/layer_timelapse.py

@@ -15,7 +15,7 @@ from pathlib import Path
 from backend.app.core.config import settings
 from backend.app.services.camera import apply_camera_rotation
 from backend.app.services.external_camera import capture_frame
-from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
+from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, X264_BOUNDED_ARGS, summarize_ffmpeg_stderr
 
 logger = logging.getLogger(__name__)
 
@@ -40,6 +40,10 @@ _STITCH_SECONDS_PER_FRAME = 0.5
 _STITCH_MIN_SECONDS = 300.0
 _STITCH_MAX_SECONDS = 3600.0
 
+# Frames larger than 1080p (counted in pixels, so a portrait camera keeps its
+# shape) are scaled down to it; both sides are made even, as yuv420p requires.
+STITCH_SCALE_FILTER = "scale=w='if(gt(iw*ih,2073600),trunc(iw*sqrt(2073600/(iw*ih))/2)*2,trunc(iw/2)*2)':h=-2"
+
 # A stitch that fails keeps its frames, marked with this file, so the video can
 # still be made from them; the orphan janitor leaves them for this long.
 KEPT_FRAMES_MARKER = "STITCH_FAILED"
@@ -191,6 +195,8 @@ class TimelapseSession:
             "0",
             "-i",
             str(concat_file),
+            "-vf",
+            STITCH_SCALE_FILTER,
             "-c:v",
             "libx264",
             "-pix_fmt",
@@ -199,6 +205,8 @@ class TimelapseSession:
             "medium",
             "-crf",
             "23",
+            # Bounded memory; see X264_BOUNDED_ARGS (#3292).
+            *X264_BOUNDED_ARGS,
             str(output_path),
         ]
 
@@ -229,18 +237,27 @@ class TimelapseSession:
 
         except TimeoutError:
             logger.error("Timelapse stitching timed out after %.0f s (%s frames)", timeout, len(frame_files))
-            if process:
+            output_path.unlink(missing_ok=True)
+            return False
+        except Exception as e:
+            logger.error("Timelapse stitch failed: %s", e)
+            output_path.unlink(missing_ok=True)
+            return False
+        except asyncio.CancelledError:
+            # A stitch cut short leaves a truncated file behind; not a video.
+            output_path.unlink(missing_ok=True)
+            raise
+        finally:
+            # However the stitch ends -- its deadline, an error, or the task
+            # being cancelled -- an ffmpeg it started does not keep running and
+            # holding memory (#3292).
+            if process is not None and process.returncode is None:
                 with contextlib.suppress(ProcessLookupError):
                     process.kill()
                 try:
                     await asyncio.wait_for(process.wait(), 10)
                 except TimeoutError:
                     logger.warning("ffmpeg did not exit after the timelapse stitch was killed")
-            output_path.unlink(missing_ok=True)
-            return False
-        except Exception as e:
-            logger.error("Timelapse stitch failed: %s", e)
-            return False
 
     def mark_kept(self, reason: str) -> bool:
         """Mark the frames to be kept for KEEP_FAILED_FRAMES_SECONDS."""
@@ -248,7 +265,7 @@ class TimelapseSession:
             marker = self.frames_dir / KEPT_FRAMES_MARKER  # SEC-PATH-OK: this module's constant filename
             marker.write_text(
                 f"{reason}\nThese frames are kept for 24 hours. To make the video from them, run in this folder:\n"
-                "ffmpeg -f concat -safe 0 -i frames.txt -c:v libx264 -pix_fmt yuv420p timelapse.mp4\n"
+                f'ffmpeg -f concat -safe 0 -i frames.txt -vf "{STITCH_SCALE_FILTER}" -c:v libx264 -pix_fmt yuv420p -threads 2 timelapse.mp4\n'
             )
             return True
         except OSError as e:

+ 3 - 1
backend/app/services/timelapse_processor.py

@@ -9,7 +9,7 @@ from collections.abc import Callable
 from pathlib import Path
 
 from backend.app.services.camera import get_ffmpeg_path
-from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
+from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, X264_BOUNDED_ARGS, summarize_ffmpeg_stderr
 
 logger = logging.getLogger(__name__)
 
@@ -261,6 +261,8 @@ class TimelapseProcessor:
                 "fast",
                 "-crf",
                 "23",
+                # Bounded memory, as for the layer-timelapse stitch (#3292).
+                *X264_BOUNDED_ARGS,
                 "-c:a",
                 "aac",
                 "-b:a",

+ 31 - 0
backend/app/utils/ffmpeg_output.py

@@ -41,6 +41,14 @@ from backend.app.core.logging_filters import redact_url_credentials
 
 logger = logging.getLogger(__name__)
 
+# Encoder options that bound libx264's memory (#3292). ffmpeg runs in
+# Bambuddy's own cgroup, so in a container its memory counts against the same
+# limit. Left automatic, x264 sizes its threads from the host's cores, not the
+# container's CPU limit, and a 1080p layer-timelapse stitch peaked at about
+# 1.2 GiB on a 30-core host (4K about 4.6 GiB). Two threads and a short
+# lookahead brought 1080p to about 300 MiB with the same preset and quality.
+X264_BOUNDED_ARGS = ("-threads", "2", "-x264-params", "rc-lookahead=10")
+
 # What ffmpeg prints before it has anything to say. Every line of the banner is
 # either the version line or an indented continuation, and a real diagnostic is
 # never indented this way, so the match is on the exact prefixes rather than on
@@ -179,3 +187,26 @@ async def watch_ffmpeg_log(process: asyncio.subprocess.Process) -> None:
             refuse_rtsp_redirect(process, window[window.index(_RTSP_REDIRECT_LOG) :])
             return
         tail = window[-len(_RTSP_REDIRECT_LOG) :]
+
+
+async def reap_ffmpeg(process: asyncio.subprocess.Process | None, timeout: float = 10.0) -> None:
+    """Kill ``process`` if it is still running and wait for it to exit (#3292).
+
+    For the ``finally`` of a one-shot capture: an error while reading ffmpeg's
+    output, or the capture being cancelled, otherwise leaves it running. In a
+    container it shares Bambuddy's memory limit, so every one left behind
+    counts against it until the container restarts. Never raises.
+    """
+    if process is None or process.returncode is not None:
+        return
+    with contextlib.suppress(ProcessLookupError):
+        process.kill()
+    try:
+        await asyncio.wait_for(process.wait(), timeout)
+    except TimeoutError:
+        if process.returncode is None:
+            logger.warning("ffmpeg (pid %s) did not exit after it was killed", process.pid)
+        else:
+            # wait() also waits for the output pipes to close, which can lag
+            # behind a child that has already gone.
+            logger.warning("ffmpeg (pid %s) was killed but its output did not close in %.0f s", process.pid, timeout)

+ 168 - 0
backend/tests/unit/services/test_ffmpeg_reaped_3292.py

@@ -0,0 +1,168 @@
+"""A one-shot ffmpeg never outlives the capture that started it (#3292).
+
+The camera captures killed their ffmpeg on their own deadline and nowhere
+else: an error while reading its output, or the capture being cancelled, left
+it running. ffmpeg shares Bambuddy's memory limit in a container, so each one
+left behind counts against it until the container restarts.
+"""
+
+import asyncio
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services import camera, external_camera
+from backend.app.utils.ffmpeg_output import reap_ffmpeg
+
+
+def _running_process(**overrides) -> MagicMock:
+    proc = MagicMock()
+    proc.pid = 4242
+    proc.returncode = None
+    proc.wait = AsyncMock(return_value=-9)
+    proc.communicate = AsyncMock(side_effect=RuntimeError("pipe closed"))
+    for key, value in overrides.items():
+        setattr(proc, key, value)
+    return proc
+
+
+class TestReapFfmpeg:
+    @pytest.mark.asyncio
+    async def test_a_running_process_is_killed_and_waited_for(self):
+        proc = _running_process()
+        await reap_ffmpeg(proc)
+        proc.kill.assert_called_once()
+        proc.wait.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_finished_process_is_left_alone(self):
+        proc = _running_process(returncode=0)
+        await reap_ffmpeg(proc)
+        proc.kill.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_nothing_to_reap(self):
+        await reap_ffmpeg(None)
+
+    @pytest.mark.asyncio
+    async def test_a_process_gone_meanwhile_is_fine(self):
+        proc = _running_process()
+        proc.kill.side_effect = ProcessLookupError
+        await reap_ffmpeg(proc)
+
+    @pytest.mark.asyncio
+    async def test_one_that_will_not_exit_does_not_hang_the_caller(self):
+        async def never():
+            await asyncio.Event().wait()
+
+        proc = _running_process(wait=never)
+        await asyncio.wait_for(reap_ffmpeg(proc, timeout=0.05), 2)
+        proc.kill.assert_called_once()
+
+
+class TestEveryCaptureReapsItsFfmpeg:
+    @pytest.mark.asyncio
+    async def test_external_rtsp_capture(self):
+        proc = _running_process()
+        with (
+            patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch.object(external_camera, "_sanitize_camera_url", side_effect=lambda url, schemes: url),
+            patch.object(external_camera, "read_ffmpeg_log_until_open", AsyncMock(side_effect=RuntimeError("boom"))),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
+            # Handled one level up, as before; what matters is the process.
+            pytest.raises(RuntimeError),
+        ):
+            await external_camera._capture_rtsp_frame("rtsp://camera.example/stream", 5)
+
+        proc.kill.assert_called_once()
+        proc.wait.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_external_rtsp_capture_cancelled(self):
+        async def hang(_process):
+            await asyncio.Event().wait()
+
+        proc = _running_process()
+        with (
+            patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch.object(external_camera, "_sanitize_camera_url", side_effect=lambda url, schemes: url),
+            patch.object(external_camera, "read_ffmpeg_log_until_open", hang),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
+        ):
+            task = asyncio.create_task(external_camera._capture_rtsp_frame("rtsp://camera.example/stream", 5))
+            await asyncio.sleep(0.05)
+            task.cancel()
+            with pytest.raises(asyncio.CancelledError):
+                await task
+
+        proc.kill.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_usb_capture(self):
+        proc = _running_process()
+        with (
+            patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch.object(external_camera, "_safe_usb_device_path", side_effect=lambda device: device),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
+            pytest.raises(RuntimeError),
+        ):
+            await external_camera._capture_usb_frame("/dev/video0", 5)
+
+        proc.kill.assert_called_once()
+        proc.wait.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_printer_rtsp_capture(self):
+        proc = _running_process()
+        with (
+            patch.object(camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch.object(camera, "create_tls_proxy", AsyncMock(return_value=(40000, None))),
+            patch.object(camera, "close_tls_proxy", AsyncMock()),
+            patch.object(camera, "read_ffmpeg_log_until_open", AsyncMock(side_effect=RuntimeError("boom"))),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
+        ):
+            assert await camera._capture_camera_frame_bytes_uncoalesced("192.0.2.10", "code", "X1C", 5) is None
+
+        proc.kill.assert_called_once()
+        proc.wait.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_last_frame_of_a_video(self, tmp_path):
+        (tmp_path / "in.mp4").write_bytes(b"video")
+        proc = _running_process()
+        with (
+            patch.object(camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
+            pytest.raises(RuntimeError),
+        ):
+            await camera.extract_video_last_frame(Path(tmp_path / "in.mp4"), Path(tmp_path / "out.jpg"))
+
+        proc.kill.assert_called_once()
+        proc.wait.assert_awaited()
+
+
+@pytest.mark.asyncio
+async def test_an_rtsp_capture_whose_ffmpeg_cannot_start_returns_none():
+    """Nothing to reap, and the finally must not trip over the missing process."""
+    with (
+        patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+        patch.object(external_camera, "_sanitize_camera_url", side_effect=lambda url, schemes: url),
+        patch("asyncio.create_subprocess_exec", AsyncMock(side_effect=PermissionError("not executable"))),
+    ):
+        assert await external_camera._capture_rtsp_frame("rtsp://camera.example/stream", 5) is None
+
+
+@pytest.mark.asyncio
+async def test_a_killed_process_whose_output_lingers_is_not_called_still_running(caplog):
+    """wait() also waits for the pipes; a dead child can still keep it waiting."""
+
+    async def pipes_open():
+        proc.returncode = -9
+        await asyncio.Event().wait()
+
+    proc = _running_process()
+    proc.wait = pipes_open
+    await reap_ffmpeg(proc, timeout=0.05)
+    assert "did not exit" not in caplog.text
+    assert "output did not close" in caplog.text

+ 1 - 0
backend/tests/unit/services/test_layer_timelapse_stitch_deadline.py

@@ -51,6 +51,7 @@ async def test_a_stitch_past_its_deadline_is_killed_and_reaped(base_dir, monkeyp
         await asyncio.Event().wait()
 
     proc.communicate = never
+    proc.returncode = None  # still running when the deadline passes
     proc.wait = AsyncMock(return_value=-9)
     monkeypatch.setattr(layer_timelapse, "_stitch_timeout", lambda _frames: 0.05)
 

+ 281 - 0
backend/tests/unit/services/test_layer_timelapse_stitch_memory_3292.py

@@ -0,0 +1,281 @@
+"""The layer-timelapse stitch has bounded memory and never outlives its caller (#3292).
+
+The stitch ran libx264 with no thread limit. x264 sizes its threads from the
+cores it can see, and in a container that is the node's, not the pod's CPU
+limit: measured over 300 frames, a 1080p stitch peaked at 1.2 GiB on a 30-core
+host and a 4K one at 5.3 GiB. ffmpeg runs in Bambuddy's own cgroup, so the
+reporter's pod was OOM-killed the moment a long print finished, and the
+timelapse was lost.
+
+Two threads, a short lookahead and frames no larger than 1080p bring a 1080p
+stitch to about 320 MiB with the same preset and quality. And an ffmpeg the
+stitch started is killed and reaped however the stitch ends, not only on its
+own deadline.
+"""
+
+import asyncio
+import json
+import shutil
+import subprocess
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services import layer_timelapse
+from backend.app.services.layer_timelapse import TimelapseSession
+
+
+@pytest.fixture
+def base_dir(tmp_path, monkeypatch):
+    fake = MagicMock(base_dir=tmp_path)
+    monkeypatch.setattr(layer_timelapse, "settings", fake)
+    layer_timelapse._active_sessions.clear()
+    layer_timelapse._finalizing_sessions.clear()
+    yield tmp_path
+    layer_timelapse._active_sessions.clear()
+    layer_timelapse._finalizing_sessions.clear()
+
+
+def _session(frames: int = 3) -> TimelapseSession:
+    session = TimelapseSession(printer_id=1, archive_id=1, camera_url="http://cam/", camera_type="mjpeg")
+    for n in range(frames):
+        (session.frames_dir / f"layer_{n:05d}.jpg").write_bytes(b"\xff\xd8jpg\xff\xd9")
+    session.frame_count = frames
+    return session
+
+
+def _running_process(communicate) -> MagicMock:
+    proc = MagicMock()
+    proc.returncode = None
+    proc.communicate = communicate
+    proc.wait = AsyncMock(return_value=-9)
+    return proc
+
+
+async def _stitch_command(base_dir) -> list[str]:
+    session = _session()
+    proc = MagicMock()
+    proc.returncode = 0
+    proc.communicate = AsyncMock(return_value=(b"", b""))
+    spawn = AsyncMock(return_value=proc)
+    with (
+        patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+        patch("asyncio.create_subprocess_exec", spawn),
+    ):
+        await session.stitch(base_dir / "out.mp4")
+    return list(spawn.await_args.args)
+
+
+class TestBoundedMemory:
+    @pytest.mark.asyncio
+    async def test_two_encoder_threads_and_a_short_lookahead(self, base_dir):
+        cmd = await _stitch_command(base_dir)
+        assert cmd[cmd.index("-threads") + 1] == "2"
+        assert "rc-lookahead=10" in cmd[cmd.index("-x264-params") + 1]
+
+    @pytest.mark.asyncio
+    async def test_frames_are_scaled_down_to_1080p(self, base_dir):
+        cmd = await _stitch_command(base_dir)
+        assert cmd[cmd.index("-vf") + 1] == layer_timelapse.STITCH_SCALE_FILTER
+
+    @pytest.mark.asyncio
+    async def test_the_quality_settings_are_unchanged(self, base_dir):
+        cmd = await _stitch_command(base_dir)
+        assert cmd[cmd.index("-preset") + 1] == "medium"
+        assert cmd[cmd.index("-crf") + 1] == "23"
+        assert cmd[-1] == str(base_dir / "out.mp4")
+
+
+class TestFfmpegNeverOutlivesTheStitch:
+    @pytest.mark.asyncio
+    async def test_killed_and_reaped_when_the_stitch_is_cancelled(self, base_dir):
+        async def hang():
+            await asyncio.Event().wait()
+
+        proc = _running_process(hang)
+        with (
+            patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
+        ):
+            task = asyncio.create_task(_session().stitch(base_dir / "out.mp4"))
+            await asyncio.sleep(0.05)
+            task.cancel()
+            with pytest.raises(asyncio.CancelledError):
+                await task
+
+        proc.kill.assert_called_once()
+        proc.wait.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_killed_and_reaped_on_any_other_error(self, base_dir):
+        async def broken():
+            raise RuntimeError("pipe closed")
+
+        proc = _running_process(broken)
+        with (
+            patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
+        ):
+            assert await _session().stitch(base_dir / "out.mp4") is False
+
+        proc.kill.assert_called_once()
+        proc.wait.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_finished_ffmpeg_is_left_alone(self, base_dir):
+        proc = MagicMock()
+        proc.returncode = 0
+        proc.communicate = AsyncMock(return_value=(b"", b""))
+        with (
+            patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
+        ):
+            assert await _session().stitch(base_dir / "out.mp4") is True
+
+        proc.kill.assert_not_called()
+
+
+FFMPEG = shutil.which("ffmpeg")
+FFPROBE = shutil.which("ffprobe")
+
+
+@pytest.mark.skipif(not (FFMPEG and FFPROBE), reason="needs ffmpeg and ffprobe")
+class TestTheScaleFilter:
+    """Run the filter through a real ffmpeg: what it does to real frame sizes."""
+
+    @staticmethod
+    def _scaled_size(tmp_path, width: int, height: int) -> tuple[int, int]:
+        frame = tmp_path / f"in_{width}x{height}.png"
+        subprocess.run(
+            [
+                FFMPEG,
+                "-loglevel",
+                "error",
+                "-y",
+                "-f",
+                "lavfi",
+                "-i",
+                f"color=c=gray:s={width}x{height}",
+                "-frames:v",
+                "1",
+                str(frame),
+            ],
+            check=True,
+        )
+        out = tmp_path / f"out_{width}x{height}.png"
+        subprocess.run(
+            [
+                FFMPEG,
+                "-loglevel",
+                "error",
+                "-y",
+                "-i",
+                str(frame),
+                "-vf",
+                layer_timelapse.STITCH_SCALE_FILTER,
+                str(out),
+            ],
+            check=True,
+        )
+        probe = subprocess.run(
+            [
+                FFPROBE,
+                "-v",
+                "error",
+                "-select_streams",
+                "v:0",
+                "-show_entries",
+                "stream=width,height",
+                "-of",
+                "json",
+                str(out),
+            ],
+            check=True,
+            capture_output=True,
+            text=True,
+        )
+        stream = json.loads(probe.stdout)["streams"][0]
+        return stream["width"], stream["height"]
+
+    def test_4k_comes_out_at_1080p(self, tmp_path):
+        assert self._scaled_size(tmp_path, 3840, 2160) == (1920, 1080)
+
+    def test_a_portrait_4k_frame_keeps_its_shape(self, tmp_path):
+        assert self._scaled_size(tmp_path, 2160, 3840) == (1080, 1920)
+
+    def test_1080p_and_smaller_are_left_as_they_are(self, tmp_path):
+        assert self._scaled_size(tmp_path, 1920, 1080) == (1920, 1080)
+        assert self._scaled_size(tmp_path, 720, 1280) == (720, 1280)
+
+    def test_an_odd_size_is_made_even_for_yuv420p(self, tmp_path):
+        assert self._scaled_size(tmp_path, 641, 481) == (640, 480)
+
+
+class TestNoPartialVideo:
+    @pytest.mark.asyncio
+    async def test_a_cancelled_stitch_removes_what_it_wrote(self, base_dir):
+        out = base_dir / "out.mp4"
+
+        async def hang():
+            out.write_bytes(b"truncated")
+            await asyncio.Event().wait()
+
+        proc = _running_process(hang)
+        with (
+            patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
+        ):
+            task = asyncio.create_task(_session().stitch(out))
+            await asyncio.sleep(0.05)
+            task.cancel()
+            with pytest.raises(asyncio.CancelledError):
+                await task
+
+        assert not out.exists()
+
+    @pytest.mark.asyncio
+    async def test_a_stitch_that_errors_removes_what_it_wrote(self, base_dir):
+        out = base_dir / "out.mp4"
+
+        async def broken():
+            out.write_bytes(b"truncated")
+            raise RuntimeError("pipe closed")
+
+        with (
+            patch.object(layer_timelapse, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_running_process(broken))),
+        ):
+            assert await _session().stitch(out) is False
+
+        assert not out.exists()
+
+
+def test_the_kept_frames_note_gives_the_same_bounded_command(base_dir):
+    session = _session()
+    assert session.mark_kept("test")
+    note = (session.frames_dir / layer_timelapse.KEPT_FRAMES_MARKER).read_text()
+    assert f'-vf "{layer_timelapse.STITCH_SCALE_FILTER}"' in note
+    assert "-threads 2" in note
+
+
+@pytest.mark.asyncio
+async def test_the_timelapse_editor_encode_is_bounded_too(tmp_path):
+    from backend.app.services import timelapse_processor
+    from backend.app.utils.ffmpeg_output import X264_BOUNDED_ARGS
+
+    proc = MagicMock()
+    proc.returncode = 0
+    proc.stderr.read = AsyncMock(return_value=b"")
+    proc.stdout.readline = AsyncMock(return_value=b"")
+    proc.wait = AsyncMock(return_value=0)
+    spawn = AsyncMock(return_value=proc)
+    with (
+        patch.object(timelapse_processor, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+        patch("asyncio.create_subprocess_exec", spawn),
+    ):
+        processor = timelapse_processor.TimelapseProcessor(tmp_path / "in.mp4")
+        await processor.process(tmp_path / "out.mp4", speed=2.0)
+
+    cmd = list(spawn.await_args.args)
+    n = len(X264_BOUNDED_ARGS)
+    assert any(tuple(cmd[i : i + n]) == X264_BOUNDED_ARGS for i in range(len(cmd)))