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)