Fix timelapse OOM on Pi 2: pre-resize images via Pillow before ffmpeg The ffmpe...

File: CLAUDE.md
 Tests use `unittest.mock` to mock gphoto2 subprocess calls. Test file: `backend/
 - **Config key fallbacks**: Nikon cameras use `f-number` instead of `aperture` and may use `shutterspeed2` instead of `shutterspeed`. The getter and setter both probe for the correct key.
 - **Path sanitization**: Gallery names are validated to allow only `[a-zA-Z0-9._\- ]`
 - **Pydantic models**: Used for request/response validation (`ExposureSettings`, `CaptureRequest`, `StackRequest`, `TimelapseRequest`, `CreateGalleryRequest`)
-- **Timelapse generation**: Uses ffmpeg subprocess to encode images into 4K 60fps MP4 videos. Concat demuxer approach handles arbitrary filenames. Runs as a background job.
+- **Timelapse generation**: Pre-resizes images to target resolution via Pillow, then encodes with ffmpeg (ultrafast preset). Default 1920x1080 @ 30fps for Pi 2 compatibility. 4K available as an option. Concat demuxer handles arbitrary filenames. Runs as a background job.
 - **Image formats**: Supports .jpg, .jpeg, .png, .tif, .cr2, .nef, .arw
 ### Frontend (React/JSX)
File: backend/main.py
 class StackRequest(BaseModel):
 class TimelapseRequest(BaseModel):
     images: list[str]
-    fps: int = 60
-    resolution: str = "3840x2160"
+    fps: int = 30
+    resolution: str = "1920x1080"
     output_name: Optional[str] = None
File: backend/timelapse.py
 """
 Timelapse video generation from gallery images.
-Uses ffmpeg to encode a sequence of images into a 4K 60fps MP4 video.
-Images are sorted alphabetically and scaled/padded to fit 3840x2160.
-
-Memory-efficient: ffmpeg handles all image decoding and encoding in a
-streaming fashion, so RAM usage stays low regardless of image count.
+Uses ffmpeg to encode a sequence of images into an MP4 video.
+Images are pre-resized to the target resolution using Pillow before
+encoding, keeping RAM usage low on constrained devices like Raspberry Pi.
 """
 import logging
 from pathlib import Path
 from typing import Optional
+from PIL import Image
+
 logger = logging.getLogger(__name__)
 def check_ffmpeg() -> bool:
     return shutil.which("ffmpeg") is not None
+def _resize_image(src: Path, dst: Path, width: int, height: int) -> None:
+    """Resize a single image to fit within width x height, with black padding.
+
+    Processes one image at a time to keep memory usage minimal on low-RAM
+    devices like Raspberry Pi 2.
+    """
+    with Image.open(src) as img:
+        img.thumbnail((width, height), Image.LANCZOS)
+        # Create black canvas at target size and paste centered
+        canvas = Image.new("RGB", (width, height), (0, 0, 0))
+        x = (width - img.width) // 2
+        y = (height - img.height) // 2
+        # Convert to RGB if needed (handles RGBA, palette, etc.)
+        if img.mode != "RGB":
+            img = img.convert("RGB")
+        canvas.paste(img, (x, y))
+        canvas.save(dst, "JPEG", quality=92)
+
+
 def generate_timelapse(
     image_paths: list[Path],
     output_path: Path,
     fps: int = 60,
-    resolution: str = "3840x2160",
+    resolution: str = "1920x1080",
     threads: int = 0,
     on_progress=None,
     cancel_check=None,
 ) -> Path:
     """
     Generate a timelapse video from a list of image files.
+    Images are pre-resized to the target resolution using Pillow before
+    being passed to ffmpeg.  This avoids ffmpeg needing to decode large
+    camera RAW/TIFF files in memory, which can OOM on devices like the
+    Raspberry Pi 2.
+
     Args:
         image_paths: Ordered list of image file paths.
         output_path: Destination path for the output MP4 file.
         fps: Frames per second (default 60).
-        resolution: Output resolution as "WxH" (default "3840x2160").
+        resolution: Output resolution as "WxH" (default "1920x1080").
         on_progress: Optional callback(frames_processed, total_frames).
         cancel_check: Optional callable returning True if job should abort.
 def generate_timelapse(
         total, output_path.name, fps, width, height,
     )
-    # Build a concat demuxer file listing all images with their duration.
-    # This avoids requiring sequential filenames and handles arbitrary names.
-    frame_duration = 1.0 / fps
+    # Phase 1: Pre-resize images to temp directory as JPEGs.
+    # This keeps ffmpeg memory usage minimal — it only decodes small JPEGs
+    # instead of full-resolution camera images (24MP+).
+    tmp_dir = tempfile.mkdtemp(prefix="timelapse_frames_")
     concat_file = None
     try:
+        resized_paths = []
+        logger.info("Pre-resizing %d images to %dx%d ...", total, width, height)
+        for i, p in enumerate(image_paths):
+            if cancel_check and cancel_check():
+                raise RuntimeError("Timelapse generation cancelled")
+
+            dst = Path(tmp_dir) / f"frame_{i:06d}.jpg"
+            _resize_image(p, dst, width, height)
+            resized_paths.append(dst)
+
+            if on_progress:
+                # Resize is ~40% of the work, encoding is ~60%
+                on_progress(0, total)
+
+        logger.info("Pre-resize complete, building ffmpeg concat list")
+
+        # Phase 2: Build concat demuxer file from pre-resized frames.
+        frame_duration = 1.0 / fps
         concat_file = tempfile.NamedTemporaryFile(
             mode="w", suffix=".txt", delete=False, prefix="timelapse_"
         )
-        for p in image_paths:
-            # ffmpeg concat demuxer requires paths to use forward slashes
-            # and single quotes around paths with special chars.
-            safe_path = str(p.resolve()).replace("'", "'\\''")
+        for rp in resized_paths:
+            safe_path = str(rp).replace("'", "'\\''")
             concat_file.write(f"file '{safe_path}'\n")
             concat_file.write(f"duration {frame_duration}\n")
-        # Repeat last image to avoid it being skipped
-        safe_last = str(image_paths[-1].resolve()).replace("'", "'\\''")
+        # Repeat last frame to avoid it being skipped
+        safe_last = str(resized_paths[-1]).replace("'", "'\\''")
         concat_file.write(f"file '{safe_last}'\n")
         concat_file.flush()
         concat_path = concat_file.name
         concat_file.close()
-        # Build ffmpeg command
+        # Phase 3: Encode with ffmpeg — no scaling filter needed since
+        # frames are already at the target resolution.
         cmd = [
             "ffmpeg",
             "-y",  # overwrite output
             "-f", "concat",
             "-safe", "0",
             "-i", concat_path,
-            "-vf", (
-                f"scale={width}:{height}"
-                ":force_original_aspect_ratio=decrease,"
-                f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black"
-            ),
             "-c:v", "libx264",
-            "-preset", "medium",
-            "-crf", "18",
+            "-preset", "ultrafast",
+            "-crf", "20",
             "-pix_fmt", "yuv420p",
             "-movflags", "+faststart",
             "-progress", "pipe:1",
 def generate_timelapse(
             if cancel_check and cancel_check():
                 proc.kill()
                 proc.wait()
-                # Clean up partial output
                 if output_path.exists():
                     output_path.unlink()
                 raise RuntimeError("Timelapse generation cancelled")
 def generate_timelapse(
         if proc.returncode != 0:
             stderr = proc.stderr.read()
             logger.error("ffmpeg failed (rc=%d): %s", proc.returncode, stderr)
-            # Clean up partial output
             if output_path.exists():
                 output_path.unlink()
             raise RuntimeError(f"ffmpeg failed: {stderr[-500:]}")
 def generate_timelapse(
         return output_path
     finally:
-        # Clean up concat file
+        # Clean up temp files
         if concat_file:
             Path(concat_file.name).unlink(missing_ok=True)
+        shutil.rmtree(tmp_dir, ignore_errors=True)
Read more...