diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index a4aa2d8..0000000 --- a/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ - .git - __pycache__ - *.pyc - *.pyo - *.pyd - .venv - .env - output/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 537f266..39f681c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..20b479a --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 369179c..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/compile_timelapse.py b/compile_timelapse.py deleted file mode 100644 index 990dab8..0000000 --- a/compile_timelapse.py +++ /dev/null @@ -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 diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..b915620 --- /dev/null +++ b/config.example.toml @@ -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 diff --git a/image_processing.py b/image_processing.py deleted file mode 100644 index 7c3b8d1..0000000 --- a/image_processing.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/immich_api.py b/immich_api.py deleted file mode 100644 index a20b1e3..0000000 --- a/immich_api.py +++ /dev/null @@ -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 diff --git a/main.py b/main.py deleted file mode 100644 index 3b1c52c..0000000 --- a/main.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 84a793d..0000000 Binary files a/requirements.txt and /dev/null differ diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..25c6300 --- /dev/null +++ b/src/config.rs @@ -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) -> crate::error::Result { + 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()); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..5f29e35 --- /dev/null +++ b/src/error.rs @@ -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 = std::result::Result; diff --git a/src/face_processing/mod.rs b/src/face_processing/mod.rs new file mode 100644 index 0000000..998771e --- /dev/null +++ b/src/face_processing/mod.rs @@ -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; diff --git a/src/face_processing/types.rs b/src/face_processing/types.rs new file mode 100644 index 0000000..f7c21ef --- /dev/null +++ b/src/face_processing/types.rs @@ -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, +} + +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::() / 6.0; + let y = eye_points.iter().map(|p| p.y).sum::() / 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::() / 6.0; + let y = eye_points.iter().map(|p| p.y).sum::() / 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, + /// 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 }, +} diff --git a/src/immich_api/mod.rs b/src/immich_api/mod.rs new file mode 100644 index 0000000..e45aa89 --- /dev/null +++ b/src/immich_api/mod.rs @@ -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, +} + +/// Asset data from Immich. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Asset { + pub id: String, + pub device_asset_id: Option, + pub original_file_name: Option, + pub file_created_at: Option, + pub local_date_time: Option, + pub people: Option>, +} + +/// Person with face data. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonWithFaces { + pub id: String, + pub name: Option, + pub faces: Option>, +} + +/// 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, + pub next_page: Option, +} + +/// Search parameters. +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub person_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub taken_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub taken_before: Option, + #[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 { + 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 { + 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> { + 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(¶ms) + .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 { + 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> { + 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, + } + + 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()); + } +} diff --git a/src/job/mod.rs b/src/job/mod.rs new file mode 100644 index 0000000..562106c --- /dev/null +++ b/src/job/mod.rs @@ -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 diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f1d9735 --- /dev/null +++ b/src/lib.rs @@ -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}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d0317f6 --- /dev/null +++ b/src/main.rs @@ -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 { + // 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) +} diff --git a/src/video/ffmpeg.rs b/src/video/ffmpeg.rs new file mode 100644 index 0000000..2a746fe --- /dev/null +++ b/src/video/ffmpeg.rs @@ -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( + 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::() { + 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 { + 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) +} diff --git a/src/video/mod.rs b/src/video/mod.rs new file mode 100644 index 0000000..937c4a0 --- /dev/null +++ b/src/video/mod.rs @@ -0,0 +1,5 @@ +//! Video compilation module. + +mod ffmpeg; + +pub use ffmpeg::*; diff --git a/src/web/handlers.rs b/src/web/handlers.rs new file mode 100644 index 0000000..d9bfc93 --- /dev/null +++ b/src/web/handlers.rs @@ -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, + error: Option, +} + +async fn check_connection(State(state): State) -> Json { + 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, +} + +async fn get_people( + State(state): State, +) -> Result>, (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 = 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, +} + +async fn get_progress(State(state): State) -> Json { + 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, + date_to: Option, + // Processing options can be added here +} + +#[derive(Serialize)] +struct StartResponse { + success: bool, + message: String, +} + +async fn start_processing( + State(state): State, + Json(request): Json, +) -> Result, (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) -> Json { + 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(), + }) + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..7530b23 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,7 @@ +//! Web server module. + +mod handlers; +mod state; + +pub use handlers::*; +pub use state::*; diff --git a/src/web/state.rs b/src/web/state.rs new file mode 100644 index 0000000..70db59b --- /dev/null +++ b/src/web/state.rs @@ -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, +} + +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>, + + /// Current job progress. + pub progress: Arc>, + + /// Channel for broadcasting progress updates. + pub progress_tx: broadcast::Sender, + + /// Cancellation signal sender. + pub cancel_tx: Arc>>>, +} + +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); + } +} diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index f2c2245..0000000 --- a/templates/index.html +++ /dev/null @@ -1,354 +0,0 @@ - - - - - Immich Selfie Timelapse Tool - - - -
-

Immich Selfie Timelapse Tool

- - -
- Checking Immich server connection... -
- - {% if warning %} -
{{ warning }}
- {% endif %} - - {% if error %} -

{{ error }}

- {% endif %} - -
-
Person Selection
-
- -
- -
Date Range (Optional)
-
-
- -
-
- -
-
- -
Face Processing Settings
-
- - - Width and height of the output images in pixels. -
-
- - - Minimum width/height of detected faces in pixels. -
-
- - - Maximum allowed head angle in degrees (0 degrees is when subject is looking straight at the camera). -
-
- -
- -
Video Output
-
-
- - -
-
-
- -
- - - -
- {% if message %} -

{{ message }}

- {% endif %} - -
- -

0%

- -
-
- - - \ No newline at end of file