init rust rewrite
This commit is contained in:
parent
d664c29a79
commit
d8e4d0834c
24 changed files with 1314 additions and 1328 deletions
|
|
@ -1,8 +0,0 @@
|
|||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.venv
|
||||
.env
|
||||
output/
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -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
49
Cargo.toml
Normal 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"
|
||||
37
Dockerfile
37
Dockerfile
|
|
@ -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"]
|
||||
|
|
@ -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
51
config.example.toml
Normal 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
|
||||
|
|
@ -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
|
||||
112
immich_api.py
112
immich_api.py
|
|
@ -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
204
main.py
|
|
@ -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)
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
227
src/config.rs
Normal file
227
src/config.rs
Normal 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
46
src/error.rs
Normal 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>;
|
||||
13
src/face_processing/mod.rs
Normal file
13
src/face_processing/mod.rs
Normal 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;
|
||||
138
src/face_processing/types.rs
Normal file
138
src/face_processing/types.rs
Normal 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
253
src/immich_api/mod.rs
Normal 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(¶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<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
8
src/job/mod.rs
Normal 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
17
src/lib.rs
Normal 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
62
src/main.rs
Normal 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
123
src/video/ffmpeg.rs
Normal 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
5
src/video/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//! Video compilation module.
|
||||
|
||||
mod ffmpeg;
|
||||
|
||||
pub use ffmpeg::*;
|
||||
214
src/web/handlers.rs
Normal file
214
src/web/handlers.rs
Normal 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
7
src/web/mod.rs
Normal 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
87
src/web/state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue