init rust rewrite

This commit is contained in:
Arnaud Cayrol 2026-01-24 11:32:19 +01:00
parent d664c29a79
commit d8e4d0834c
24 changed files with 1314 additions and 1328 deletions

View file

@ -1,8 +0,0 @@
.git
__pycache__
*.pyc
*.pyo
*.pyd
.venv
.env
output/

18
.gitignore vendored
View file

@ -1,6 +1,16 @@
# Rust
/target
Cargo.lock
# IDE
.idea
dlib-19.24.99-cp312-cp312-win_amd64.whl
mmod_human_face_detector.dat
shape_predictor_68_face_landmarks.dat
.vscode
# Output
output/
__pycache__/
# Configuration with secrets
config.toml
# OS
.DS_Store

49
Cargo.toml Normal file
View file

@ -0,0 +1,49 @@
[package]
name = "immich-timelapse"
version = "0.1.0"
edition = "2021"
description = "Create selfie timelapses from Immich using face recognition and alignment"
license = "MIT"
repository = "https://github.com/ArnaudCrl/immich-automated-selfie-timelapse"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# Web framework
axum = { version = "0.7", features = ["ws"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["fs", "cors"] }
# HTTP client
reqwest = { version = "0.12", features = ["json", "stream"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# Image processing
image = { version = "0.25", features = ["jpeg", "png", "webp"] }
imageproc = "0.25"
# Error handling
thiserror = "1"
anyhow = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Utilities
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
directories = "5"
bytes = "1"
[dev-dependencies]
tokio-test = "0.4"
[[bin]]
name = "immich-timelapse"
path = "src/main.rs"

View file

@ -1,37 +0,0 @@
FROM python:3.10-slim
# Install system dependencies required for OpenCV and dlib
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
cmake \
ffmpeg \
libsm6 \
libxext6 \
libxrender-dev \
libgl1-mesa-glx \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy requirements first to leverage Docker cache
COPY . .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Download the shape predictor model
RUN apt-get update && apt-get install -y wget && \
wget -nd https://github.com/JeffTrain/selfie/raw/master/shape_predictor_68_face_landmarks.dat && \
apt-get remove -y wget && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# Create output directory
RUN mkdir -p /app/output
# Expose the port the app runs on
EXPOSE 5000
# Command to run the application
CMD ["python", "main.py"]

View file

@ -1,103 +0,0 @@
import os
import subprocess
import logging
import tempfile
import shutil
from typing import Callable
from pathlib import Path
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%H:%M:%S'
)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
def compile_timelapse(
image_folder: str,
output_path: str,
framerate: int,
update_progress: Callable[[int, int], None]
) -> bool:
"""
Compile a timelapse video from a folder of images using ffmpeg.
Args:
image_folder: Path to folder containing timestamped JPEG images.
output_path: Path where the output video should be saved.
framerate: Desired frames per second.
update_progress: Callback function to report progress (current: int, total: int).
Returns:
True if successful, False otherwise.
"""
try:
# List and sort JPEG image files in the folder.
image_files = sorted([
f for f in os.listdir(image_folder)
if f.lower().endswith(('.jpg', '.jpeg'))
])
total_frames = len(image_files)
if total_frames == 0:
logger.error("No image files found in folder")
update_progress(0, 0)
return False
logger.info(f"Found {total_frames} images to process")
try:
input_pattern = os.path.join(image_folder, "*.jpg")
cmd = [
"ffmpeg",
"-pattern_type", "glob",
"-framerate", str(framerate),
"-i", input_pattern,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-y",
output_path
]
logger.info("Running ffmpeg command: " + " ".join(cmd))
# Run ffmpeg and capture the stderr for progress updates.
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True
)
frame_count = 0
for line in process.stderr:
if not line:
break
print(line, end='') # Output the line to the console.
if "frame=" in line:
try:
frame = int(line.split("frame=")[1].split()[0])
frame_count = min(frame, total_frames)
update_progress(frame_count, total_frames)
except (IndexError, ValueError):
continue
process.wait()
if process.returncode == 0:
update_progress(total_frames, total_frames)
logger.info("Timelapse video created successfully")
return True
else:
logger.error(f"ffmpeg failed with return code {process.returncode}")
update_progress(0, total_frames)
return False
finally:
logger.info(f"Video created successfully")
except Exception as e:
logger.error(f"Error compiling timelapse: {str(e)}")
update_progress(0, total_frames if 'total_frames' in locals() else 0)
return False

51
config.example.toml Normal file
View file

@ -0,0 +1,51 @@
# Immich Selfie Timelapse Configuration
# Copy this file to config.toml and fill in your values.
# Environment variables (IMMICH_API_KEY, IMMICH_BASE_URL) override these settings.
# Output directory for processed images and video
output_dir = "output"
[api]
# Your Immich API key (get from Immich user settings)
api_key = "your-api-key-here"
# Immich server URL (include /api suffix)
base_url = "http://192.168.1.100:2283/api"
# Request timeout in seconds
timeout_secs = 30
[processing]
# Output image size (width and height in pixels)
resize_size = 512
# Minimum face size in pixels (faces smaller than this are skipped)
face_resolution_threshold = 80
# Maximum head yaw angle in degrees (filters out turned heads)
pose_threshold = 25.0
# Eye Aspect Ratio threshold (filters out closed eyes)
ear_threshold = 0.2
# Target position for left eye as [x%, y%] from top-left
left_eye_pos = [0.35, 0.4]
# Target position for right eye as [x%, y%] from top-left
right_eye_pos = [0.65, 0.4]
# Number of parallel workers (defaults to CPU count)
# max_workers = 4
[video]
# Output video framerate
framerate = 15
# Whether to compile video after processing
enabled = true
# Video codec
codec = "libx264"
# Constant Rate Factor (quality: lower = better, 18-28 recommended)
crf = 23

View file

@ -1,506 +0,0 @@
# image_processing.py
import os
import io
import concurrent.futures
from datetime import datetime
from dataclasses import dataclass
from PIL import Image, ImageOps
import numpy as np
import cv2
import dlib
from tqdm import tqdm
import logging
from typing import Tuple
from immich_api import get_assets_with_person, download_asset
class TqdmLoggingHandler(logging.Handler):
def __init__(self, level=logging.NOTSET):
super().__init__(level)
def emit(self, record):
try:
msg = self.format(record)
tqdm.write(msg)
except Exception:
self.handleError(record)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
tqdm_handler = TqdmLoggingHandler()
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', datefmt='%H:%M:%S')
tqdm_handler.setFormatter(formatter)
logger.addHandler(tqdm_handler)
@dataclass
class AppConfig:
"""Configuration for the application."""
api_key: str
base_url: str
person_id: str
output_folder: str
landmark_model: str
resize_size: int
face_resolution_threshold: int
pose_threshold: float
left_eye_pos: Tuple[float, float]
date_from: str
date_to: str
def initialize_worker(landmark_model_path: str) -> None:
"""Initialize worker process with face predictor.
Args:
landmark_model_path: Path to the landmark model file
"""
global face_predictor
face_predictor = dlib.shape_predictor(landmark_model_path)
def detect_landmarks(image, face_data, face_resolution_threshold):
"""
Detects facial landmarks in the image, resizing if necessary for better detection.
Args:
image (PIL.Image): The input image.
face_data (dict): Face metadata containing bounding box information.
max_landmark_size (int): Maximum size for landmark detection.
Returns:
dict or None: Dictionary containing facial landmarks in numpy arrays if successful,
None if face resolution is too low.
"""
# Get face rectangle from metadata with proper scaling
face_img_width = face_data.get("imageWidth")
face_img_height = face_data.get("imageHeight")
img_width, img_height = image.size
scale_x = img_width / face_img_width
scale_y = img_height / face_img_height
x1 = int(face_data.get("boundingBoxX1", 0) * scale_x)
x2 = int(face_data.get("boundingBoxX2", 0) * scale_x)
y1 = int(face_data.get("boundingBoxY1", 0) * scale_y)
y2 = int(face_data.get("boundingBoxY2", 0) * scale_y)
w = x2 - x1
h = y2 - y1
if w < face_resolution_threshold or h < face_resolution_threshold:
return None
# Crop the face region from the image
face_crop = image.crop((x1, y1, x2, y2))
# If the cropped face is too large, resize it for better landmark detection
optimal_size = 256 # optimal size for landmark detection
scale_factor = 1.0
if w > optimal_size or h > optimal_size:
scale_factor = optimal_size / max(w, h)
new_w = int(w * scale_factor)
new_h = int(h * scale_factor)
face_crop = face_crop.resize((new_w, new_h), Image.Resampling.LANCZOS)
w, h = new_w, new_h
# Convert PIL Image to numpy array for OpenCV processing
img_np = np.array(face_crop)
img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
# Create dlib rectangle spanning the whole cropped image
face_rect = dlib.rectangle(0, 0, w, h)
# Detect facial landmarks
shape = face_predictor(img_np, face_rect)
if not shape:
return None
# Scale landmarks back to original image coordinates and adjust for face rectangle position
landmarks = {}
for i in range(68):
x = shape.part(i).x
y = shape.part(i).y
if scale_factor != 1.0:
x = int(x / scale_factor)
y = int(y / scale_factor)
# Add the face rectangle's top-left coordinates to position landmarks correctly
x += x1
y += y1
landmarks[i] = (x, y)
# Convert to numpy arrays for specific facial features
left_eye = np.array([landmarks[i] for i in range(36, 42)])
right_eye = np.array([landmarks[i] for i in range(42, 48)])
nose_tip = np.array(landmarks[30])
chin = np.array(landmarks[8])
left_mouth = np.array(landmarks[48])
right_mouth = np.array(landmarks[54])
return {
'left_eye': left_eye,
'right_eye': right_eye,
'nose_tip': nose_tip,
'chin': chin,
'left_mouth': left_mouth,
'right_mouth': right_mouth,
'all_landmarks': landmarks
}
def check_eye_visibility(left_eye, right_eye, ear_threshold=0.2) -> bool:
"""
Checks if both eyes are visible by calculating the Eye Aspect Ratio (EAR).
Args:
left_eye (numpy.ndarray): Array of left eye landmarks.
right_eye (numpy.ndarray): Array of right eye landmarks.
ear_threshold (float): Threshold for eye visibility.
Returns:
bool: True if both eyes are open enough, False otherwise.
"""
def calculate_ear(eye):
v1 = np.linalg.norm(eye[1] - eye[5])
v2 = np.linalg.norm(eye[2] - eye[4])
h = np.linalg.norm(eye[0] - eye[3])
ear = (v1 + v2) / (2.0 * h)
return ear
left_ear = calculate_ear(left_eye)
right_ear = calculate_ear(right_eye)
if left_ear < ear_threshold or right_ear < ear_threshold:
return False
return True
def get_head_pose(landmarks, image):
"""
Estimates the head pose (pitch, yaw, roll) using facial landmarks.
Based on https://learnopencv.com/head-pose-estimation-using-opencv-and-dlib/
Args:
landmarks (dict): Dictionary containing facial landmarks in numpy arrays.
image (PIL.Image): The input image.
Returns:
tuple or None: (pitch, yaw, roll) in degrees if successful; otherwise None.
"""
# Get image size
img_width, img_height = image.size
image_points = np.array([
landmarks['nose_tip'],
landmarks['chin'],
landmarks['left_eye'][0],
landmarks['right_eye'][3],
landmarks['left_mouth'],
landmarks['right_mouth']
], dtype="double")
model_points = np.array([
(0.0, 0.0, 0.0), # Nose tip
(0.0, -330.0, -65.0), # Chin
(-225.0, 170.0, -135.0), # Left eye left corner
(225.0, 170.0, -135.0), # Right eye right corner
(-150.0, -150.0, -125.0), # Left Mouth corner
(150.0, -150.0, -125.0) # Right mouth corner
])
focal_length = img_width
center = (img_width / 2, img_height / 2)
camera_matrix = np.array(
[[focal_length, 0, center[0]],
[0, focal_length, center[1]],
[0, 0, 1]], dtype="double"
)
dist_coeffs = np.zeros((4, 1))
success, rotation_vector, translation_vector = cv2.solvePnP(
model_points, image_points, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_ITERATIVE
)
if not success:
logger.info("Head pose estimation failed in solvePnP.")
return None
rotation_matrix, _ = cv2.Rodrigues(rotation_vector)
proj_matrix = np.hstack((rotation_matrix, translation_vector))
_, _, _, _, _, _, euler_angles = cv2.decomposeProjectionMatrix(proj_matrix)
pitch, yaw, roll = [float(angle) for angle in euler_angles]
# Normalize angles to be between -180 and 180 degrees
pitch = (pitch + 180) % 360 - 180
yaw = (yaw + 180) % 360 - 180
roll = (roll + 180) % 360 - 180
# Adjust pitch to be between -90 and 90 degrees
if pitch > 90:
pitch = 180 - pitch
elif pitch < -90:
pitch = -180 - pitch
return pitch, yaw, roll
def calculate_eye_alignment_transform(
left_eye_center: np.ndarray,
right_eye_center: np.ndarray,
output_size: int,
desired_left_eye_pos: Tuple[float, float]
) -> np.ndarray:
"""Calculate the transformation matrix to align eyes at desired positions.
Args:
left_eye_center: Center coordinates of the left eye
right_eye_center: Center coordinates of the right eye
output_size: Size of the output image (width and height)
desired_left_eye_pos: Desired position of left eye as percentages (x, y)
Returns:
np.ndarray: 2x3 transformation matrix for cv2.warpAffine
"""
# Calculate the desired eye positions in the output image
left_eye_target = np.array([
output_size * desired_left_eye_pos[0],
output_size * desired_left_eye_pos[1]
])
right_eye_target = np.array([
output_size * (1.0 - desired_left_eye_pos[0]),
output_size * desired_left_eye_pos[1]
])
# Calculate the angle between the current eye line and the target eye line
current_angle = np.degrees(np.arctan2(
right_eye_center[1] - left_eye_center[1],
right_eye_center[0] - left_eye_center[0]
))
target_angle = np.degrees(np.arctan2(
right_eye_target[1] - left_eye_target[1],
right_eye_target[0] - left_eye_target[0]
))
rotation_angle = target_angle - current_angle
# Calculate the scale factor to match the desired eye distance
current_eye_distance = np.linalg.norm(right_eye_center - left_eye_center)
target_eye_distance = np.linalg.norm(right_eye_target - left_eye_target)
scale = target_eye_distance / current_eye_distance
# Create the transformation matrix
# First, translate to origin (center of eyes)
center = np.array([
(left_eye_center[0] + right_eye_center[0]) / 2,
(left_eye_center[1] + right_eye_center[1]) / 2
])
M1 = np.array([
[1, 0, -center[0]],
[0, 1, -center[1]],
[0, 0, 1]
])
# Then rotate
angle_rad = np.radians(rotation_angle)
M2 = np.array([
[np.cos(angle_rad), -np.sin(angle_rad), 0],
[np.sin(angle_rad), np.cos(angle_rad), 0],
[0, 0, 1]
])
# Then scale
M3 = np.array([
[scale, 0, 0],
[0, scale, 0],
[0, 0, 1]
])
# Finally, translate to target position
target_center = np.array([
(left_eye_target[0] + right_eye_target[0]) / 2,
(left_eye_target[1] + right_eye_target[1]) / 2
])
M4 = np.array([
[1, 0, target_center[0]],
[0, 1, target_center[1]],
[0, 0, 1]
])
# Combine all transformations
M = M4 @ M3 @ M2 @ M1
# Convert to 2x3 matrix for OpenCV
return M[:2, :]
def crop_and_align_face(image, face_data, resize_size, face_resolution_threshold, pose_threshold, left_eye_pos):
"""
Aligns a face in an image by positioning the eyes at specified locations.
Args:
image (PIL.Image): The input image.
face_data (dict): Face metadata containing bounding box information.
resize_size (int): Size to resize the output image to.
face_resolution_threshold (int): Minimum face resolution threshold.
pose_threshold (float): Maximum allowed head pose deviation.
left_eye_pos (tuple): Desired position of the left eye in the output as percentages (x, y).
Returns:
PIL.Image or None: The aligned face image if successful, None otherwise.
"""
try:
# Convert image to numpy array for OpenCV processing
img_np = np.array(image)
img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
# Detect landmarks in the face region
landmarks = detect_landmarks(image, face_data, face_resolution_threshold)
if not landmarks:
logger.info("Face resolution is too low")
return None
# Check if both eyes are visible
if not check_eye_visibility(landmarks['left_eye'], landmarks['right_eye']):
logger.info("Eyes are too closed")
return None
# Get head pose
pose = get_head_pose(landmarks, image)
if not pose:
logger.info("Could not estimate head pose")
return None
# Check if head pose is within acceptable range
pitch, yaw, roll = pose
if abs(yaw) > pose_threshold:
logger.info(f"Head pose exceeds threshold: pitch={pitch:.1f}°, yaw={yaw:.1f}°, roll={roll:.1f}°")
return None
# Get eye positions
left_eye_center = np.mean(landmarks['left_eye'], axis=0)
right_eye_center = np.mean(landmarks['right_eye'], axis=0)
# Calculate transformation matrix
rotation_matrix = calculate_eye_alignment_transform(
left_eye_center,
right_eye_center,
resize_size,
left_eye_pos
)
# Apply transformation
aligned_face = cv2.warpAffine(
img_np,
rotation_matrix,
(resize_size, resize_size),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE
)
# Convert back to PIL Image
aligned_face = cv2.cvtColor(aligned_face, cv2.COLOR_BGR2RGB)
aligned_face = Image.fromarray(aligned_face)
return aligned_face
except Exception as e:
logger.error(f"Error in crop_and_align_face: {str(e)}")
return None
def process_asset_worker(asset, config: AppConfig):
"""
Worker function to process a single asset.
This function downloads the asset, crops the face based on metadata,
verifies resolution, aligns the face, and then saves the aligned face.
Args:
asset (dict): The asset metadata.
config (AppConfig): Configuration parameters.
Returns:
str or None: The file path of the saved image if processing is successful; otherwise None.
"""
try:
asset_id = asset['id']
image_bytes = download_asset(config.api_key, config.base_url, asset_id)
image = Image.open(io.BytesIO(image_bytes))
image = ImageOps.exif_transpose(image)
image = image.convert("RGB")
except Exception as e:
logger.info(f"Error processing asset {asset.get('id')}: {e}")
return None
matching_person = next((p for p in asset.get('people', []) if p.get('id') == config.person_id), None)
face_data = matching_person.get('faces', [])[0]
aligned_face = crop_and_align_face(
image,
face_data,
resize_size=config.resize_size,
face_resolution_threshold=config.face_resolution_threshold,
pose_threshold=config.pose_threshold,
left_eye_pos=config.left_eye_pos
)
if aligned_face is None:
return None
dt = datetime.fromisoformat(asset['fileCreatedAt'].replace("Z", "+00:00"))
timestamp = dt.strftime("%Y%m%d_%H%M%S")
filename = os.path.join(config.output_folder, f"{timestamp}.jpg")
aligned_face.save(filename)
return filename
def process_faces(config: AppConfig, max_workers=1, progress_callback=None, cancel_flag=None):
"""
Processes assets containing the person and saves aligned face images.
This function retrieves assets from the API, then uses a process pool to
concurrently download, crop, and align faces.
Args:
config (AppConfig): Configuration parameters.
max_workers (int): Number of worker processes.
progress_callback (callable, optional): A callback function for progress updates.
cancel_flag (callable, optional): A function that returns True if processing should be cancelled.
Returns:
list: A list of file paths of the saved images.
"""
if cancel_flag and cancel_flag():
logger.info("Processing was cancelled.")
return []
assets = get_assets_with_person(config.api_key, config.base_url, config.person_id, config.date_from, config.date_to)
logger.info(f"Found {len(assets)} assets containing the person.")
total_assets = len(assets)
if progress_callback:
progress_callback(0, total_assets)
processed_files = []
completed_count = 0
initializer_args = [config.landmark_model]
with concurrent.futures.ProcessPoolExecutor(
max_workers=max_workers,
initializer=initialize_worker,
initargs=initializer_args) as executor:
future_to_asset = {executor.submit(process_asset_worker, asset, config): asset
for asset in assets}
for future in tqdm(concurrent.futures.as_completed(future_to_asset), total=total_assets):
if cancel_flag and cancel_flag():
logger.info("Processing was cancelled.")
for f in future_to_asset:
f.cancel()
executor.shutdown(wait=False)
return processed_files
try:
result = future.result()
if result is not None:
processed_files.append(result)
except Exception as e:
logger.info(f"Asset processing failed: {e}")
completed_count += 1
if progress_callback:
progress_callback(completed_count, total_assets)
logger.info(f"Finished processing. {len(processed_files)} images saved out of {total_assets} assets.")
return processed_files

View file

@ -1,112 +0,0 @@
import requests
import logging
logger = logging.getLogger(__name__)
def validate_immich_connection(api_key, base_url):
"""
Validates that the provided Immich API key and base URL are working.
Args:
api_key (str): API key for authentication.
base_url (str): Base URL of the API.
Returns:
tuple: (bool, str) - (is_valid, error_message)
"""
if not api_key or not base_url:
return False, "API key and base URL are required."
try:
headers = {
'Accept': 'application/json',
'x-api-key': api_key,
}
# Try a simple ping to the server via the user endpoint
url = f"{base_url}/server/about"
response = requests.get(url, headers=headers, timeout=5)
if response.status_code == 200:
return True, "Connection successful."
elif response.status_code == 401:
return False, "Authentication failed. Invalid API key."
else:
return False, f"Server error: Status code {response.status_code}"
except requests.exceptions.ConnectionError:
return False, "Connection error. Check the base URL."
except requests.exceptions.Timeout:
return False, "Connection timed out. Server might be down."
except Exception as e:
return False, f"Unexpected error: {str(e)}"
def get_assets_with_person(api_key, base_url, person_id, date_from=None, date_to=None):
"""
Retrieve all image assets containing the specified person by querying the API.
Args:
api_key (str): API key for authentication.
base_url (str): Base URL of the API.
person_id (str): ID of the person to search for.
date_from (str, optional): Start date in ISO format (YYYY-MM-DD).
date_to (str, optional): End date in ISO format (YYYY-MM-DD).
Returns:
list: List of asset dictionaries.
"""
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'x-api-key': api_key,
}
url = f"{base_url}/search/metadata"
all_assets = []
payload = {
"page": 1,
"type": "IMAGE",
"personIds": [person_id],
"withArchived": False,
"withDeleted": True,
"withExif": True,
"withPeople": True,
"withStacked": True,
}
if date_from:
payload["takenAfter"] = f"{date_from}T00:00:00.000Z"
if date_to:
payload["takenBefore"] = f"{date_to}T23:59:59.999Z"
while payload["page"] is not None:
response = requests.post(url, headers=headers, json=payload)
if response.status_code != 200:
logger.info(f"Error fetching page {payload['page']}: {response.status_code} - {response.text}")
break
data = response.json()
if not data:
break
all_assets.extend(data['assets']['items'])
logger.info(f"Fetched page {payload['page']} with {len(data['assets']['items'])} assets")
payload["page"] = data['assets'].get('nextPage')
return all_assets
def download_asset(api_key, base_url, asset_id):
"""
Downloads the original image asset from the API.
Args:
api_key (str): API key for authentication.
base_url (str): Base URL of the API.
asset_id (str): The asset's ID.
Returns:
bytes: The content of the downloaded image.
"""
headers = {'x-api-key': api_key}
response = requests.get(f'{base_url}/assets/{asset_id}/original', headers=headers)
response.raise_for_status()
return response.content

204
main.py
View file

@ -1,204 +0,0 @@
import logging
import multiprocessing
import os
import threading
from dataclasses import dataclass
from typing import Callable, Dict, List, Tuple
from flask import Flask, jsonify, render_template, request
from image_processing import process_faces
from immich_api import validate_immich_connection
from compile_timelapse import compile_timelapse
# Filter out progress route logs
class ProgressRouteFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return "/progress" not in record.getMessage()
log = logging.getLogger('werkzeug')
log.addFilter(ProgressRouteFilter())
@dataclass
class AppConfig:
"""Configuration for the application."""
api_key: str = os.environ.get("IMMICH_API_KEY", "")
base_url: str = os.environ.get("IMMICH_BASE_URL", "")
person_id: str = None
output_folder: str = "output"
landmark_model: str = "shape_predictor_68_face_landmarks.dat"
resize_size: int = 512
face_resolution_threshold: int = 80
pose_threshold: float = 25.0
left_eye_pos: Tuple[float, float] = (0.35, 0.4)
date_from: str = None
date_to: str = None
# Initialize Flask app
app = Flask(__name__)
# Global state
AVAILABLE_CORES = multiprocessing.cpu_count()
progress_info: Dict[str, any] = {"completed": 0, "total": 0, "status": "idle"}
processing_thread: threading.Thread = None
cancel_requested: bool = False
config = AppConfig()
def update_progress(current: int, total: int) -> None:
"""Update the global progress information.
Args:
current: Number of completed tasks
total: Total number of tasks
"""
progress_info["completed"] = current
progress_info["total"] = total
progress_info["status"] = "running" if current < total else "done"
def check_output_folder() -> Tuple[bool, int]:
"""Check if the output folder is empty.
Returns:
Tuple containing (is_empty, file_count)
"""
if not os.path.exists(config.output_folder):
os.makedirs(config.output_folder, exist_ok=True)
return True, 0
files = [f for f in os.listdir(config.output_folder)
if os.path.isfile(os.path.join(config.output_folder, f))]
return len(files) == 0, len(files)
def background_process(
max_workers: int = 1,
progress_callback: Callable = None,
cancel_flag: Callable = None,
do_not_compile_video: bool = False,
framerate: int = 15
) -> List[str]:
"""Process faces in the background and optionally compile a timelapse video.
Args:
max_workers: Number of worker processes for face processing
progress_callback: Optional callback for progress updates
cancel_flag: Optional function to check for cancellation
do_not_compile_video: Whether to not compile a timelapse video after processing
framerate: Frames per second for the output video
"""
try:
# Process faces
process_faces(
config=config,
max_workers=max_workers,
progress_callback=progress_callback,
cancel_flag=cancel_flag
)
if progress_callback:
progress_callback(1, 1)
if not do_not_compile_video and not cancel_flag():
progress_info["status"] = "compiling_video"
video_output_path = os.path.join(config.output_folder, "timelapse.mp4")
success = compile_timelapse(
image_folder=config.output_folder,
output_path=video_output_path,
framerate=framerate,
update_progress=progress_callback
)
progress_info["status"] = "video_done" if success else "error:Video compilation failed"
except Exception as e:
raise
@app.route("/progress")
def progress() -> Dict[str, any]:
"""Get current progress information."""
return jsonify(progress_info)
@app.route("/check-connection")
def check_connection() -> Dict[str, any]:
"""Check connection to Immich server."""
is_valid, message = validate_immich_connection(config.api_key, config.base_url)
return jsonify({"valid": is_valid, "message": message})
@app.route("/cancel", methods=["POST"])
def cancel() -> Dict[str, any]:
"""Cancel the current processing job."""
global processing_thread, cancel_requested
cancel_requested = True
if processing_thread and processing_thread.is_alive():
progress_info["status"] = "cancelled"
return jsonify({"success": True, "message": "Processing cancelled."})
cancel_requested = False
return jsonify({"success": False, "message": "No active processing to cancel."})
@app.route("/", methods=["GET", "POST"])
def index() -> str:
"""Handle the main page and processing requests."""
global processing_thread, cancel_requested
message = None
error = None
warning = None
# Check output folder status
is_empty, file_count = check_output_folder()
if not is_empty:
warning = f"Output folder is not empty. Contains {file_count} files. New images will be added to this folder."
# Validate connection on POST
if request.method == "POST":
is_valid, message = validate_immich_connection(config.api_key, config.base_url)
if not is_valid:
error = f"Immich server connection error: {message}"
return render_template("index.html", error=error, warning=warning,
max_workers_options=list(range(1, AVAILABLE_CORES + 1)))
try:
cancel_requested = False
# Get form data
config.person_id = request.form["person_id"]
config.resize_size = int(request.form.get("resize_size"))
config.face_resolution_threshold = int(request.form.get("face_resolution_threshold"))
config.pose_threshold = float(request.form.get("pose_threshold"))
config.date_from = request.form.get("date_from")
config.date_to = request.form.get("date_to")
max_workers = int(request.form.get("max_workers"))
do_not_compile_video = request.form.get("do_not_compile_video") == "on"
framerate = int(request.form.get("framerate", 15))
# Reset progress info
progress_info.update({
"completed": 0,
"total": 0,
"status": "idle"
})
# Start processing
processing_thread = threading.Thread(
target=background_process,
kwargs={
"max_workers": max_workers,
"progress_callback": update_progress,
"cancel_flag": lambda: cancel_requested,
"do_not_compile_video": do_not_compile_video,
"framerate": framerate
}
)
processing_thread.start()
message = "Processing started. Please wait and watch the progress bar below."
except Exception as e:
error = f"Error processing request: {e}"
return render_template("index.html",
message=message,
error=error,
warning=warning,
max_workers_options=list(range(1, AVAILABLE_CORES + 1)))
if __name__ == "__main__":
app.run(host='0.0.0.0', port=5000, debug=False)

Binary file not shown.

227
src/config.rs Normal file
View file

@ -0,0 +1,227 @@
//! Application configuration.
//!
//! Configuration can be loaded from:
//! 1. TOML file (config.toml)
//! 2. Environment variables (prefixed with IMMICH_)
//! 3. Programmatic overrides
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Main configuration struct.
///
/// This is the single source of truth for all configuration.
/// Pass this explicitly to functions that need it - no globals.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Immich API configuration.
pub api: ApiConfig,
/// Face processing parameters.
pub processing: ProcessingConfig,
/// Video output settings.
pub video: VideoConfig,
/// Output directory for processed images and video.
pub output_dir: PathBuf,
}
impl Default for Config {
fn default() -> Self {
Self {
api: ApiConfig::default(),
processing: ProcessingConfig::default(),
video: VideoConfig::default(),
output_dir: PathBuf::from("output"),
}
}
}
/// Immich API connection settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiConfig {
/// API key for authentication.
pub api_key: String,
/// Base URL of the Immich instance (e.g., "http://192.168.1.94:2283/api").
pub base_url: String,
/// Request timeout in seconds.
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
fn default_timeout() -> u64 {
30
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
api_key: String::new(),
base_url: String::new(),
timeout_secs: default_timeout(),
}
}
}
/// Face processing parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProcessingConfig {
/// Output image size (width and height in pixels).
pub resize_size: u32,
/// Minimum face width/height in pixels.
pub face_resolution_threshold: u32,
/// Maximum allowed head yaw angle in degrees.
pub pose_threshold: f32,
/// Eye Aspect Ratio threshold for eye visibility.
pub ear_threshold: f32,
/// Target position for left eye as (x%, y%).
pub left_eye_pos: (f32, f32),
/// Target position for right eye as (x%, y%).
pub right_eye_pos: (f32, f32),
/// Number of parallel workers for processing.
pub max_workers: usize,
}
impl Default for ProcessingConfig {
fn default() -> Self {
Self {
resize_size: 512,
face_resolution_threshold: 80,
pose_threshold: 25.0,
ear_threshold: 0.2,
left_eye_pos: (0.35, 0.4),
right_eye_pos: (0.65, 0.4),
max_workers: num_cpus(),
}
}
}
fn num_cpus() -> usize {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(4)
}
/// Video compilation settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoConfig {
/// Output video framerate.
pub framerate: u32,
/// Whether to compile video after processing.
pub enabled: bool,
/// Video codec (e.g., "libx264").
pub codec: String,
/// Constant Rate Factor for quality (lower = better, 18-28 recommended).
pub crf: u32,
}
impl Default for VideoConfig {
fn default() -> Self {
Self {
framerate: 15,
enabled: true,
codec: "libx264".to_string(),
crf: 23,
}
}
}
impl Config {
/// Load configuration from a TOML file.
pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::error::Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)
.map_err(|e| crate::error::Error::Config(e.to_string()))?;
Ok(config)
}
/// Load configuration from environment variables.
///
/// Recognized variables:
/// - IMMICH_API_KEY
/// - IMMICH_BASE_URL
pub fn from_env() -> Self {
let mut config = Config::default();
if let Ok(key) = std::env::var("IMMICH_API_KEY") {
config.api.api_key = key;
}
if let Ok(url) = std::env::var("IMMICH_BASE_URL") {
config.api.base_url = url;
}
if let Ok(dir) = std::env::var("OUTPUT_DIR") {
config.output_dir = PathBuf::from(dir);
}
config
}
/// Merge environment variables into an existing config.
pub fn with_env(mut self) -> Self {
if let Ok(key) = std::env::var("IMMICH_API_KEY") {
self.api.api_key = key;
}
if let Ok(url) = std::env::var("IMMICH_BASE_URL") {
self.api.base_url = url;
}
if let Ok(dir) = std::env::var("OUTPUT_DIR") {
self.output_dir = PathBuf::from(dir);
}
self
}
/// Validate the configuration.
pub fn validate(&self) -> crate::error::Result<()> {
if self.api.api_key.is_empty() {
return Err(crate::error::Error::Config(
"API key is required".to_string(),
));
}
if self.api.base_url.is_empty() {
return Err(crate::error::Error::Config(
"Base URL is required".to_string(),
));
}
if self.processing.resize_size == 0 {
return Err(crate::error::Error::Config(
"Resize size must be greater than 0".to_string(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.processing.resize_size, 512);
assert_eq!(config.processing.face_resolution_threshold, 80);
assert_eq!(config.video.framerate, 15);
}
#[test]
fn test_validation_missing_api_key() {
let config = Config::default();
let result = config.validate();
assert!(result.is_err());
}
}

46
src/error.rs Normal file
View file

@ -0,0 +1,46 @@
//! Application error types.
use thiserror::Error;
/// Main error type for the application.
#[derive(Error, Debug)]
pub enum Error {
#[error("Configuration error: {0}")]
Config(String),
#[error("Immich API error: {0}")]
ImmichApi(String),
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Image processing error: {0}")]
ImageProcessing(String),
#[error("Face detection error: {0}")]
FaceDetection(String),
#[error("No face found in image")]
NoFaceFound,
#[error("Face quality check failed: {0}")]
QualityCheck(String),
#[error("Video compilation error: {0}")]
VideoCompilation(String),
#[error("FFmpeg error: {0}")]
FFmpeg(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parsing error: {0}")]
Json(#[from] serde_json::Error),
#[error("Job cancelled")]
Cancelled,
}
/// Convenience Result type.
pub type Result<T> = std::result::Result<T, Error>;

View file

@ -0,0 +1,13 @@
//! Image processing module.
//!
//! This module handles face detection, landmark detection,
//! alignment, and image transformation.
mod types;
pub use types::*;
// TODO: Implement these modules
// mod landmarks;
// mod alignment;
// mod filters;

View file

@ -0,0 +1,138 @@
//! Types for image processing.
use serde::{Deserialize, Serialize};
/// A 2D point.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Point {
pub x: f32,
pub y: f32,
}
impl Point {
pub fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
}
/// Bounding box for a face.
#[derive(Debug, Clone, Copy)]
pub struct BoundingBox {
pub x1: f32,
pub y1: f32,
pub x2: f32,
pub y2: f32,
}
impl BoundingBox {
pub fn width(&self) -> f32 {
self.x2 - self.x1
}
pub fn height(&self) -> f32 {
self.y2 - self.y1
}
pub fn center(&self) -> Point {
Point::new((self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0)
}
}
/// Facial landmarks (68-point model).
#[derive(Debug, Clone)]
pub struct Landmarks {
/// All 68 landmark points.
pub points: Vec<Point>,
}
impl Landmarks {
/// Left eye center (average of points 36-41).
pub fn left_eye_center(&self) -> Point {
let eye_points = &self.points[36..42];
let x = eye_points.iter().map(|p| p.x).sum::<f32>() / 6.0;
let y = eye_points.iter().map(|p| p.y).sum::<f32>() / 6.0;
Point::new(x, y)
}
/// Right eye center (average of points 42-47).
pub fn right_eye_center(&self) -> Point {
let eye_points = &self.points[42..48];
let x = eye_points.iter().map(|p| p.x).sum::<f32>() / 6.0;
let y = eye_points.iter().map(|p| p.y).sum::<f32>() / 6.0;
Point::new(x, y)
}
/// Nose tip (point 30).
pub fn nose_tip(&self) -> Point {
self.points[30]
}
/// Chin (point 8).
pub fn chin(&self) -> Point {
self.points[8]
}
/// Left mouth corner (point 48).
pub fn left_mouth(&self) -> Point {
self.points[48]
}
/// Right mouth corner (point 54).
pub fn right_mouth(&self) -> Point {
self.points[54]
}
}
/// Head pose angles.
#[derive(Debug, Clone, Copy)]
pub struct HeadPose {
/// Pitch (up/down tilt) in degrees.
pub pitch: f32,
/// Yaw (left/right turn) in degrees.
pub yaw: f32,
/// Roll (head tilt) in degrees.
pub roll: f32,
}
impl HeadPose {
/// Check if the pose is within acceptable thresholds.
pub fn is_frontal(&self, yaw_threshold: f32) -> bool {
self.yaw.abs() <= yaw_threshold
}
}
/// Eye Aspect Ratio for blink detection.
#[derive(Debug, Clone, Copy)]
pub struct EyeAspectRatio {
pub left: f32,
pub right: f32,
}
impl EyeAspectRatio {
/// Check if eyes are sufficiently open.
pub fn eyes_open(&self, threshold: f32) -> bool {
self.left >= threshold && self.right >= threshold
}
}
/// Result of processing a single face.
#[derive(Debug)]
pub struct ProcessedFace {
/// The aligned and cropped face image data.
pub image_data: Vec<u8>,
/// Original asset ID.
pub asset_id: String,
/// Timestamp for sorting/naming.
pub timestamp: String,
}
/// Processing result for a single asset.
#[derive(Debug)]
pub enum AssetResult {
/// Successfully processed.
Success(ProcessedFace),
/// Skipped (face too small, bad pose, etc.).
Skipped { asset_id: String, reason: String },
/// Error during processing.
Error { asset_id: String, error: String },
}

253
src/immich_api/mod.rs Normal file
View file

@ -0,0 +1,253 @@
//! Immich API client implementation.
use crate::config::ApiConfig;
use crate::error::{Error, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Client for interacting with the Immich API.
#[derive(Clone)]
pub struct ImmichClient {
client: Client,
base_url: String,
api_key: String,
}
/// Server information response.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfo {
pub version: String,
}
/// Face data from Immich.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FaceData {
pub bounding_box_x1: f32,
pub bounding_box_y1: f32,
pub bounding_box_x2: f32,
pub bounding_box_y2: f32,
pub image_width: u32,
pub image_height: u32,
}
/// Person data from Immich.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Person {
pub id: String,
pub name: Option<String>,
}
/// Asset data from Immich.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Asset {
pub id: String,
pub device_asset_id: Option<String>,
pub original_file_name: Option<String>,
pub file_created_at: Option<String>,
pub local_date_time: Option<String>,
pub people: Option<Vec<PersonWithFaces>>,
}
/// Person with face data.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonWithFaces {
pub id: String,
pub name: Option<String>,
pub faces: Option<Vec<FaceData>>,
}
/// Search response from Immich.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchResponse {
pub assets: SearchAssets,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchAssets {
pub items: Vec<Asset>,
pub next_page: Option<String>,
}
/// Search parameters.
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub person_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub taken_after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub taken_before: Option<String>,
#[serde(rename = "type")]
pub asset_type: String,
pub page: u32,
pub size: u32,
pub with_people: bool,
}
impl ImmichClient {
/// Create a new Immich client.
pub fn new(config: &ApiConfig) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()?;
Ok(Self {
client,
base_url: config.base_url.trim_end_matches('/').to_string(),
api_key: config.api_key.clone(),
})
}
/// Validate the connection to Immich.
pub async fn validate_connection(&self) -> Result<ServerInfo> {
let url = format!("{}/server/about", self.base_url);
let response = self
.client
.get(&url)
.header("x-api-key", &self.api_key)
.send()
.await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err(Error::ImmichApi("Invalid API key".to_string()));
}
if !response.status().is_success() {
return Err(Error::ImmichApi(format!(
"Server returned status {}",
response.status()
)));
}
let info: ServerInfo = response.json().await?;
Ok(info)
}
/// Search for assets containing a specific person.
pub async fn get_assets_with_person(
&self,
person_id: &str,
taken_after: Option<&str>,
taken_before: Option<&str>,
) -> Result<Vec<Asset>> {
let mut all_assets = Vec::new();
let mut page = 1u32;
loop {
let params = SearchParams {
person_ids: Some(vec![person_id.to_string()]),
taken_after: taken_after.map(|s| s.to_string()),
taken_before: taken_before.map(|s| s.to_string()),
asset_type: "IMAGE".to_string(),
page,
size: 100,
with_people: true,
};
let url = format!("{}/search/metadata", self.base_url);
let response = self
.client
.post(&url)
.header("x-api-key", &self.api_key)
.json(&params)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(Error::ImmichApi(format!(
"Search failed with status {}: {}",
status, body
)));
}
let search_response: SearchResponse = response.json().await?;
all_assets.extend(search_response.assets.items);
if search_response.assets.next_page.is_none() {
break;
}
page += 1;
}
tracing::info!("Found {} assets for person {}", all_assets.len(), person_id);
Ok(all_assets)
}
/// Download an asset's original image.
pub async fn download_asset(&self, asset_id: &str) -> Result<bytes::Bytes> {
let url = format!("{}/assets/{}/original", self.base_url, asset_id);
let response = self
.client
.get(&url)
.header("x-api-key", &self.api_key)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::ImmichApi(format!(
"Failed to download asset {}: {}",
asset_id,
response.status()
)));
}
let bytes = response.bytes().await?;
Ok(bytes)
}
/// Get all people from Immich.
pub async fn get_people(&self) -> Result<Vec<Person>> {
let url = format!("{}/people", self.base_url);
let response = self
.client
.get(&url)
.header("x-api-key", &self.api_key)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::ImmichApi(format!(
"Failed to get people: {}",
response.status()
)));
}
#[derive(Deserialize)]
struct PeopleResponse {
people: Vec<Person>,
}
let response: PeopleResponse = response.json().await?;
Ok(response.people)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let config = ApiConfig {
api_key: "test-key".to_string(),
base_url: "http://localhost:2283/api/".to_string(),
timeout_secs: 30,
};
let client = ImmichClient::new(&config);
assert!(client.is_ok());
}
}

8
src/job/mod.rs Normal file
View file

@ -0,0 +1,8 @@
//! Background job processing module.
// TODO: Implement job processor
// This will handle:
// - Fetching assets from Immich
// - Parallel face processing
// - Progress reporting
// - Cancellation handling

17
src/lib.rs Normal file
View file

@ -0,0 +1,17 @@
//! Immich Selfie Timelapse - Create selfie timelapses from Immich.
//!
//! This library provides functionality to:
//! - Connect to an Immich server and fetch photos of a specific person
//! - Detect and align faces in images
//! - Compile aligned faces into a timelapse video
pub mod config;
pub mod error;
pub mod face_processing;
pub mod immich_api;
pub mod job;
pub mod video;
pub mod web;
pub use config::Config;
pub use error::{Error, Result};

62
src/main.rs Normal file
View file

@ -0,0 +1,62 @@
//! Immich Selfie Timelapse Server
//!
//! Web server for creating selfie timelapses from Immich.
use immich_timelapse::{config::Config, web};
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize logging
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "immich_timelapse=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
// Load configuration
let config = load_config()?;
tracing::info!("Configuration loaded");
// Check ffmpeg availability
match immich_timelapse::video::check_ffmpeg().await {
Ok(version) => tracing::info!("FFmpeg available: {}", version),
Err(e) => tracing::warn!("FFmpeg not available: {} - video compilation will fail", e),
}
// Create application state
let state = web::AppState::new(config);
// Create router
let app = web::create_router(state);
// Start server
let addr = SocketAddr::from(([0, 0, 0, 0], 5000));
tracing::info!("Starting server on http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn load_config() -> anyhow::Result<Config> {
// Try loading from config.toml first
let config = if std::path::Path::new("config.toml").exists() {
tracing::info!("Loading configuration from config.toml");
Config::from_file("config.toml")?.with_env()
} else {
tracing::info!("No config.toml found, using environment variables");
Config::from_env()
};
// Validation is optional at startup - API key might be set via web UI later
if let Err(e) = config.validate() {
tracing::warn!("Configuration incomplete: {} - some features may not work", e);
}
Ok(config)
}

123
src/video/ffmpeg.rs Normal file
View file

@ -0,0 +1,123 @@
//! FFmpeg wrapper for video compilation.
use crate::config::VideoConfig;
use crate::error::{Error, Result};
use std::path::Path;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
/// Compile images into a timelapse video.
///
/// # Arguments
/// * `input_dir` - Directory containing numbered JPEG images
/// * `output_path` - Path for the output video file
/// * `config` - Video configuration
/// * `progress_callback` - Called with (current_frame, total_frames)
pub async fn compile_timelapse<F>(
input_dir: &Path,
output_path: &Path,
config: &VideoConfig,
mut progress_callback: F,
) -> Result<()>
where
F: FnMut(u32, u32),
{
// Count input images
let mut image_count = 0u32;
let mut entries = tokio::fs::read_dir(input_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().map(|e| e == "jpg").unwrap_or(false) {
image_count += 1;
}
}
if image_count == 0 {
return Err(Error::VideoCompilation(
"No images found in input directory".to_string(),
));
}
tracing::info!("Compiling {} images into video", image_count);
// Build ffmpeg command
let input_pattern = input_dir.join("*.jpg").to_string_lossy().to_string();
let mut cmd = Command::new("ffmpeg");
cmd.arg("-y") // Overwrite output
.arg("-framerate")
.arg(config.framerate.to_string())
.arg("-pattern_type")
.arg("glob")
.arg("-i")
.arg(&input_pattern)
.arg("-c:v")
.arg(&config.codec)
.arg("-crf")
.arg(config.crf.to_string())
.arg("-pix_fmt")
.arg("yuv420p")
.arg("-progress")
.arg("pipe:1")
.arg(output_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
Error::FFmpeg(format!(
"Failed to spawn ffmpeg (is it installed?): {}",
e
))
})?;
// Parse progress from stdout
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout).lines();
while let Some(line) = reader.next_line().await? {
if line.starts_with("frame=") {
if let Some(frame_str) = line.strip_prefix("frame=") {
if let Ok(frame) = frame_str.trim().parse::<u32>() {
progress_callback(frame, image_count);
}
}
}
}
let status = child.wait().await?;
if !status.success() {
return Err(Error::FFmpeg(format!(
"ffmpeg exited with status {}",
status
)));
}
progress_callback(image_count, image_count);
tracing::info!("Video compilation complete: {:?}", output_path);
Ok(())
}
/// Check if ffmpeg is available.
pub async fn check_ffmpeg() -> Result<String> {
let output = Command::new("ffmpeg")
.arg("-version")
.output()
.await
.map_err(|e| Error::FFmpeg(format!("ffmpeg not found: {}", e)))?;
if !output.status.success() {
return Err(Error::FFmpeg("ffmpeg version check failed".to_string()));
}
let version_output = String::from_utf8_lossy(&output.stdout);
let version = version_output
.lines()
.next()
.unwrap_or("unknown")
.to_string();
Ok(version)
}

5
src/video/mod.rs Normal file
View file

@ -0,0 +1,5 @@
//! Video compilation module.
mod ffmpeg;
pub use ffmpeg::*;

214
src/web/handlers.rs Normal file
View file

@ -0,0 +1,214 @@
//! HTTP route handlers.
use crate::immich_api::ImmichClient;
use crate::web::state::{AppState, JobStatus, Progress};
use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
/// Create the router with all routes.
pub fn create_router(state: AppState) -> Router {
Router::new()
.route("/api/health", get(health_check))
.route("/api/connection", get(check_connection))
.route("/api/people", get(get_people))
.route("/api/progress", get(get_progress))
.route("/api/start", post(start_processing))
.route("/api/cancel", post(cancel_processing))
.with_state(state)
}
/// Health check endpoint.
async fn health_check() -> &'static str {
"OK"
}
/// Check connection to Immich.
#[derive(Serialize)]
struct ConnectionStatus {
connected: bool,
version: Option<String>,
error: Option<String>,
}
async fn check_connection(State(state): State<AppState>) -> Json<ConnectionStatus> {
let config = state.config.read().await;
let client = match ImmichClient::new(&config.api) {
Ok(c) => c,
Err(e) => {
return Json(ConnectionStatus {
connected: false,
version: None,
error: Some(e.to_string()),
});
}
};
match client.validate_connection().await {
Ok(info) => Json(ConnectionStatus {
connected: true,
version: Some(info.version),
error: None,
}),
Err(e) => Json(ConnectionStatus {
connected: false,
version: None,
error: Some(e.to_string()),
}),
}
}
/// Get list of people from Immich.
#[derive(Serialize)]
struct PersonInfo {
id: String,
name: Option<String>,
}
async fn get_people(
State(state): State<AppState>,
) -> Result<Json<Vec<PersonInfo>>, (StatusCode, String)> {
let config = state.config.read().await;
let client = ImmichClient::new(&config.api).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create client: {}", e),
)
})?;
let people = client.get_people().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get people: {}", e),
)
})?;
let people_info: Vec<PersonInfo> = people
.into_iter()
.map(|p| PersonInfo {
id: p.id,
name: p.name,
})
.collect();
Ok(Json(people_info))
}
/// Get current progress.
#[derive(Serialize)]
struct ProgressResponse {
status: String,
completed: u32,
total: u32,
message: Option<String>,
}
async fn get_progress(State(state): State<AppState>) -> Json<ProgressResponse> {
let progress = state.progress.read().await;
let status_str = match &progress.status {
JobStatus::Idle => "idle",
JobStatus::Running => "running",
JobStatus::CompilingVideo => "compiling_video",
JobStatus::Completed => "completed",
JobStatus::Cancelled => "cancelled",
JobStatus::Error(_) => "error",
};
Json(ProgressResponse {
status: status_str.to_string(),
completed: progress.completed,
total: progress.total,
message: progress.message.clone(),
})
}
/// Start processing request.
#[derive(Deserialize)]
struct StartRequest {
person_id: String,
date_from: Option<String>,
date_to: Option<String>,
// Processing options can be added here
}
#[derive(Serialize)]
struct StartResponse {
success: bool,
message: String,
}
async fn start_processing(
State(state): State<AppState>,
Json(request): Json<StartRequest>,
) -> Result<Json<StartResponse>, (StatusCode, String)> {
// Check if already running
{
let progress = state.progress.read().await;
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
return Err((
StatusCode::CONFLICT,
"A job is already running".to_string(),
));
}
}
// Reset progress
state
.update_progress(Progress {
status: JobStatus::Running,
completed: 0,
total: 0,
message: Some("Starting...".to_string()),
})
.await;
// Create cancellation channel
let (cancel_tx, _cancel_rx) = tokio::sync::oneshot::channel();
state.set_cancel_sender(cancel_tx).await;
// TODO: Spawn actual processing task
// For now, just return success
tracing::info!(
"Starting processing for person {} (date range: {:?} - {:?})",
request.person_id,
request.date_from,
request.date_to
);
Ok(Json(StartResponse {
success: true,
message: "Processing started".to_string(),
}))
}
/// Cancel the current processing job.
async fn cancel_processing(State(state): State<AppState>) -> Json<StartResponse> {
let cancelled = state.request_cancel().await;
if cancelled {
state
.update_progress(Progress {
status: JobStatus::Cancelled,
completed: 0,
total: 0,
message: Some("Cancelled by user".to_string()),
})
.await;
Json(StartResponse {
success: true,
message: "Cancellation requested".to_string(),
})
} else {
Json(StartResponse {
success: false,
message: "No job running to cancel".to_string(),
})
}
}

7
src/web/mod.rs Normal file
View file

@ -0,0 +1,7 @@
//! Web server module.
mod handlers;
mod state;
pub use handlers::*;
pub use state::*;

87
src/web/state.rs Normal file
View file

@ -0,0 +1,87 @@
//! Application state for the web server.
use crate::config::Config;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
/// Job status.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JobStatus {
Idle,
Running,
CompilingVideo,
Completed,
Cancelled,
Error(String),
}
/// Progress information for a running job.
#[derive(Debug, Clone)]
pub struct Progress {
pub status: JobStatus,
pub completed: u32,
pub total: u32,
pub message: Option<String>,
}
impl Default for Progress {
fn default() -> Self {
Self {
status: JobStatus::Idle,
completed: 0,
total: 0,
message: None,
}
}
}
/// Shared application state.
#[derive(Clone)]
pub struct AppState {
/// Current configuration.
pub config: Arc<RwLock<Config>>,
/// Current job progress.
pub progress: Arc<RwLock<Progress>>,
/// Channel for broadcasting progress updates.
pub progress_tx: broadcast::Sender<Progress>,
/// Cancellation signal sender.
pub cancel_tx: Arc<RwLock<Option<tokio::sync::oneshot::Sender<()>>>>,
}
impl AppState {
pub fn new(config: Config) -> Self {
let (progress_tx, _) = broadcast::channel(100);
Self {
config: Arc::new(RwLock::new(config)),
progress: Arc::new(RwLock::new(Progress::default())),
progress_tx,
cancel_tx: Arc::new(RwLock::new(None)),
}
}
/// Update progress and broadcast to all listeners.
pub async fn update_progress(&self, progress: Progress) {
*self.progress.write().await = progress.clone();
let _ = self.progress_tx.send(progress);
}
/// Request cancellation of the current job.
pub async fn request_cancel(&self) -> bool {
let mut cancel_tx = self.cancel_tx.write().await;
if let Some(tx) = cancel_tx.take() {
let _ = tx.send(());
true
} else {
false
}
}
/// Set the cancellation sender for a new job.
pub async fn set_cancel_sender(&self, tx: tokio::sync::oneshot::Sender<()>) {
*self.cancel_tx.write().await = Some(tx);
}
}

View file

@ -1,354 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Immich Selfie Timelapse Tool</title>
<style>
body {
font-family: Arial, sans-serif;
background: #f5f5f5;
margin: 0;
padding: 0;
}
.container {
max-width: 700px;
margin: 50px auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
padding: 30px;
}
h1 {
text-align: center;
color: #333;
}
.connection-status {
text-align: center;
padding: 10px;
margin: 15px 0;
border-radius: 4px;
}
.connected {
background-color: #d4edda;
color: #155724;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
}
.warning {
background-color: #fff3cd;
color: #856404;
text-align: center;
padding: 10px;
margin: 15px 0;
border-radius: 4px;
}
form {
margin-top: 20px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 10px;
color: #555;
}
input[type="text"],
input[type="number"],
input[type="date"],
select {
width: calc(100% - 20px);
padding: 8px 10px;
margin-top: 4px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.checkbox-container {
display: flex;
align-items: center;
margin-top: 10px;
}
.checkbox-container label {
margin-left: 10px;
margin-bottom: 0;
}
.button-primary {
background: #28a745;
color: #fff;
padding: 10px 20px;
font-size: 16px;
border: none;
border-radius: 4px;
cursor: pointer;
display: block;
margin: 20px auto 0;
}
.button-primary:hover {
background: #218838;
}
.button-primary:disabled {
background: #6c757d;
cursor: not-allowed;
}
.button-danger {
background: #dc3545;
color: #fff;
padding: 10px 20px;
font-size: 16px;
border: none;
border-radius: 4px;
cursor: pointer;
display: block;
margin: 10px auto 0;
}
.button-danger:hover {
background: #c82333;
}
.result, .error {
text-align: center;
margin-top: 20px;
font-size: 16px;
}
.error {
color: #c00;
}
.result {
color: #080;
}
#progressContainer {
margin-top: 20px;
text-align: center;
}
progress {
width: 100%;
height: 25px;
}
.section-header {
margin-top: 25px;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
font-weight: bold;
}
.flex-row {
display: flex;
gap: 15px;
}
.flex-row > div {
flex: 1;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>Immich Selfie Timelapse Tool</h1>
<!-- Connection Status -->
<div id="connectionStatus" class="connection-status">
Checking Immich server connection...
</div>
{% if warning %}
<div class="warning">{{ warning }}</div>
{% endif %}
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" id="timelapseForm">
<div class="section-header">Person Selection</div>
<div class="form-group">
<label>
Person ID:
<input type="text" name="person_id" value="{{ request.form.person_id or '' }}" required>
</label>
</div>
<div class="section-header">Date Range (Optional)</div>
<div class="flex-row">
<div class="form-group">
<label>
From Date:
<input type="date" name="date_from" value="{{ request.form.date_from or '' }}">
</label>
</div>
<div class="form-group">
<label>
To Date:
<input type="date" name="date_to" value="{{ request.form.date_to or '' }}">
</label>
</div>
</div>
<div class="section-header">Face Processing Settings</div>
<div class="form-group">
<label for="resize_size">Output Image Size:</label>
<input type="number" step="1" name="resize_size" value="{{ request.form.resize_size or 512 }}">
<small class="form-text text-muted">Width and height of the output images in pixels.</small>
</div>
<div class="form-group">
<label for="face_resolution_threshold">Minimum Face Resolution:</label>
<input type="number" step="1" name="face_resolution_threshold" value="{{ request.form.face_resolution_threshold or 128 }}">
<small class="form-text text-muted">Minimum width/height of detected faces in pixels.</small>
</div>
<div class="form-group">
<label for="pose_threshold">Maximum Head Pose Deviation:</label>
<input type="number" step="0.1" name="pose_threshold" value="{{ request.form.pose_threshold or 25 }}">
<small class="form-text text-muted">Maximum allowed head angle in degrees (0 degrees is when subject is looking straight at the camera).</small>
</div>
<div class="form-group">
<label>
Max Workers (CPU cores):
<select name="max_workers">
{% for i in max_workers_options %}
<option value="{{ i }}" {% if request.form.max_workers|default('1')|int == i %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="section-header">Video Output</div>
<div class="form-group">
<div class="checkbox-container">
<input type="checkbox" id="do_not_compile_video" name="do_not_compile_video" {% if request.form.do_not_compile_video %}checked{% endif %}>
<label for="do_not_compile_video">Do not compile images into a video</label>
</div>
</div>
<div class="form-group" id="framerateGroup" style="display:{% if request.form.do_not_compile_video %}none{% else %}block{% endif %};">
<label>
Frames per second:
<input type="number" name="framerate" value="{{ request.form.framerate or 15 }}" min="1">
</label>
</div>
<button type="submit" id="submitButton" class="button-primary">Generate Timelapse</button>
<button type="button" id="cancelButton" class="button-danger" style="display:none;">Cancel Processing</button>
</form>
{% if message %}
<p class="result">{{ message }}</p>
{% endif %}
<!-- Progress Bar -->
<div id="progressContainer">
<progress id="progressBar" value="0" max="100"></progress>
<p id="progressText">0%</p>
<p id="videoResult" style="display:none;">
<a id="videoLink" href="#" download>Download Video</a>
</p>
</div>
</div>
<script>
// Check connection status when page loads
document.addEventListener('DOMContentLoaded', function() {
checkConnectionStatus();
// Toggle framerate field visibility based on checkbox
document.getElementById('do_not_compile_video').addEventListener('change', function() {
document.getElementById('framerateGroup').style.display = this.checked ? 'none' : 'block';
});
});
function checkConnectionStatus() {
fetch('/check-connection')
.then(response => response.json())
.then(data => {
const statusDiv = document.getElementById('connectionStatus');
const submitButton = document.getElementById('submitButton');
if (data.valid) {
statusDiv.className = 'connection-status connected';
statusDiv.textContent = 'Connected to Immich server';
submitButton.disabled = false;
} else {
statusDiv.className = 'connection-status disconnected';
statusDiv.textContent = 'Error: ' + data.message;
submitButton.disabled = true;
}
})
.catch(error => {
console.error('Error checking connection:', error);
const statusDiv = document.getElementById('connectionStatus');
statusDiv.className = 'connection-status disconnected';
statusDiv.textContent = 'Error checking Immich server connection';
document.getElementById('submitButton').disabled = true;
});
}
// Cancel button
document.getElementById('cancelButton').addEventListener('click', function() {
fetch('/cancel', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('progressText').textContent = 'Processing cancelled.';
}
})
.catch(error => console.error('Error:', error));
});
// Progress monitoring
let isProcessing = false;
function fetchProgress() {
fetch('/progress')
.then(response => response.json())
.then(data => {
const progressBar = document.getElementById("progressBar");
const progressText = document.getElementById("progressText");
const cancelButton = document.getElementById("cancelButton");
const submitButton = document.getElementById("submitButton");
const videoResult = document.getElementById("videoResult");
const videoLink = document.getElementById("videoLink");
// Show/hide cancel button based on status
if (data.status === "running") {
isProcessing = true;
cancelButton.style.display = "block";
submitButton.disabled = true;
} else {
isProcessing = false;
cancelButton.style.display = "none";
submitButton.disabled = false;
}
// Handle status messages
if (data.status === "cancelled") {
progressText.textContent = "Processing cancelled.";
progressBar.value = progressBar.max;
} else if (data.status === "done") {
progressText.textContent = "Processing complete!";
progressBar.value = progressBar.max;
} else if (data.status === "compiling_video") {
progressText.textContent = "Images generated, creating video";
} else if (data.status === "video_done") {
progressText.textContent = "Video compilation complete!";
progressBar.value = progressBar.max;
} else if (data.status.startsWith("error:")) {
progressText.textContent = data.status;
progressBar.value = progressBar.max;
} else if (data.total > 0) {
progressBar.max = data.total;
progressBar.value = data.completed;
const percent = Math.floor((data.completed / data.total) * 100);
progressText.textContent = percent + "% (" + data.completed + "/" + data.total + ")";
}
})
.catch(err => console.error('Error fetching progress:', err));
}
// Poll every 1 second
setInterval(fetchProgress, 200);
// Form submission
document.getElementById('timelapseForm').addEventListener('submit', function() {
document.getElementById("videoResult").style.display = "none";
});
</script>
</body>
</html>