Add api cred validation, cancel button, ffmpeg timelapse and date range

This commit is contained in:
Arnaud_Cayrol 2025-04-07 20:23:09 +02:00
parent 399b09960d
commit 23939cccdd
3 changed files with 392 additions and 53 deletions

98
main.py
View file

@ -1,8 +1,9 @@
import os
import multiprocessing
import threading
from flask import Flask, request, render_template, jsonify
from timelapse import process_faces, ProcessConfig
import subprocess
from flask import Flask, request, render_template, jsonify, redirect, url_for
from timelapse import process_faces, ProcessConfig, validate_immich_connection
app = Flask(__name__)
@ -20,6 +21,8 @@ AVAILABLE_CORES = multiprocessing.cpu_count()
# Global progress dictionary only one job at a time is assumed here
progress_info = {"completed": 0, "total": 0, "status": "idle"}
# Global processing thread reference
processing_thread = None
def update_progress(current, total):
@ -35,7 +38,8 @@ def update_progress(current, total):
progress_info["status"] = "running" if current < total else "done"
def background_process(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers):
def background_process(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers,
date_from, date_to, compile_video, framerate):
"""
Background process that creates a configuration object and calls process_faces.
@ -46,6 +50,10 @@ def background_process(person_id, padding_percent, resize_size, face_resolution_
face_resolution_threshold (int): Minimum required face resolution.
pose_threshold (float): Maximum allowed head pose deviation.
max_workers (int): Number of concurrent worker processes.
date_from (str): Start date for asset filtering.
date_to (str): End date for asset filtering.
compile_video (bool): Whether to compile the images into a video.
framerate (int): Frames per second for the output video.
"""
try:
progress_info["status"] = "running"
@ -65,11 +73,31 @@ def background_process(person_id, padding_percent, resize_size, face_resolution_
face_detect_model_path=FACE_DETECT_MODEL,
landmark_model_path=LANDMARK_MODEL
)
process_faces(config, max_workers=max_workers, progress_callback=update_progress)
processed_files = process_faces(config, max_workers=max_workers, progress_callback=update_progress,
date_from=date_from, date_to=date_to)
# Compile video if requested and there are processed files
if compile_video and processed_files:
output_video = os.path.join(OUTPUT_FOLDER, "timelapse.mp4")
ffmpeg_command = [
"ffmpeg", "-y",
"-framerate", str(framerate),
"-pattern_type", "glob",
"-i", os.path.join(OUTPUT_FOLDER, "*.jpg"),
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
output_video
]
try:
subprocess.run(ffmpeg_command, check=True)
progress_info["video_path"] = output_video
progress_info["status"] = "video_done"
except subprocess.CalledProcessError as e:
progress_info["status"] = f"Video compilation failed: {e}"
else:
progress_info["status"] = "done"
except Exception as e:
progress_info["status"] = f"error: {e}"
else:
progress_info["status"] = "done"
@app.route("/progress")
@ -80,15 +108,45 @@ def progress():
return jsonify(progress_info)
@app.route("/check-connection")
def check_connection():
"""
Endpoint to check the Immich server connection.
"""
is_valid, message = validate_immich_connection(API_KEY, BASE_URL)
return jsonify({"valid": is_valid, "message": message})
@app.route("/cancel", methods=["POST"])
def cancel():
"""
Endpoint to cancel the current processing job.
"""
global processing_thread
if processing_thread and processing_thread.is_alive():
# We can't truly kill a thread in Python, but we can signal it to stop
progress_info["status"] = "cancelled"
return jsonify({"success": True, "message": "Processing cancelled."})
else:
return jsonify({"success": False, "message": "No active processing to cancel."})
@app.route("/", methods=["GET", "POST"])
def index():
"""
Index route that displays the form and starts processing in a background thread on POST.
"""
global processing_thread
result = None
error = None
# Create max_workers_options as a list from 1 to AVAILABLE_CORES
max_workers_options = list(range(1, AVAILABLE_CORES + 1))
# Check if Immich server connection is valid
is_valid, message = validate_immich_connection(API_KEY, BASE_URL)
if not is_valid and request.method == "POST":
error = f"Immich server connection error: {message}"
return render_template("index.html", error=error, max_workers_options=list(range(1, AVAILABLE_CORES + 1)))
if request.method == "POST":
try:
person_id = request.form["person_id"]
@ -96,23 +154,35 @@ def index():
resize_size = int(request.form.get("resize_size", 512))
face_resolution_threshold = int(request.form.get("face_resolution_threshold", 128))
pose_threshold = float(request.form.get("pose_threshold", 25))
max_workers = int(request.form.get("max_workers", 1)) # default is 1
max_workers = int(request.form.get("max_workers", 1))
# Date ranges are optional
date_from = request.form.get("date_from") or None
date_to = request.form.get("date_to") or None
compile_video = request.form.get("compile_video") == "on"
framerate = int(request.form.get("framerate", 24))
# Reset progress info before starting
progress_info["completed"] = 0
progress_info["total"] = 0
progress_info["status"] = "idle"
progress_info.pop("video_path", None)
# Start the processing in a background thread
threading.Thread(
processing_thread = threading.Thread(
target=background_process,
args=(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers)
).start()
args=(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers,
date_from, date_to, compile_video, framerate)
)
processing_thread.start()
result = "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", result=result, error=error, max_workers_options=max_workers_options)
return render_template("index.html", result=result, error=error,
max_workers_options=list(range(1, AVAILABLE_CORES + 1)))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
app.run(host="0.0.0.0", port=5000)

View file

@ -22,9 +22,26 @@
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;
}
form {
margin-top: 20px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 10px;
@ -32,6 +49,7 @@
}
input[type="text"],
input[type="number"],
input[type="date"],
select {
width: calc(100% - 20px);
padding: 8px 10px;
@ -40,7 +58,16 @@
border-radius: 4px;
font-size: 14px;
}
button {
.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;
@ -51,9 +78,27 @@
display: block;
margin: 20px auto 0;
}
button:hover {
.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;
@ -73,44 +118,114 @@
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 error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post">
<label>
Person ID:
<input type="text" name="person_id" required>
</label>
<label>
Padding added around the face in percent:
<input type="number" step="1" name="padding_percent" value="{{ request.form.padding_percent or 30 }}">
</label>
<label>
Output image size:
<input type="number" name="resize_size" value="{{ request.form.resize_size or 512 }}">
</label>
<label>
Face resolution threshold:
<input type="number" name="face_resolution_threshold" value="{{ request.form.face_resolution_threshold or 128 }}">
</label>
<label>
Max orientation of the face (degrees):
<input type="number" step="0.1" name="pose_threshold" value="{{ request.form.pose_threshold or 25 }}">
</label>
<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>
<button type="submit">Generate Timelapse</button>
<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" 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">
</label>
</div>
<div class="form-group">
<label>
To Date:
<input type="date" name="date_to">
</label>
</div>
</div>
<div class="section-header">Face Processing Settings</div>
<div class="form-group">
<label>
Padding added around the face in percent:
<input type="number" step="1" name="padding_percent" value="{{ request.form.padding_percent or 30 }}">
</label>
</div>
<div class="form-group">
<label>
Output image size:
<input type="number" name="resize_size" value="{{ request.form.resize_size or 512 }}">
</label>
</div>
<div class="form-group">
<label>
Face resolution threshold:
<input type="number" name="face_resolution_threshold" value="{{ request.form.face_resolution_threshold or 128 }}">
</label>
</div>
<div class="form-group">
<label>
Max orientation of the face (degrees):
<input type="number" step="0.1" name="pose_threshold" value="{{ request.form.pose_threshold or 25 }}">
</label>
</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="compile_video" name="compile_video">
<label for="compile_video">Compile timelapse into video</label>
</div>
</div>
<div class="form-group" id="framerateGroup" style="display:none;">
<label>
Frames per second:
<input type="number" name="framerate" value="24" min="1" max="60">
</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 result %}
<p class="result">{{ result }}</p>
@ -119,26 +234,115 @@
<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();
});
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;
});
}
// Toggle framerate field visibility based on checkbox
document.getElementById('compile_video').addEventListener('change', function() {
document.getElementById('framerateGroup').style.display = this.checked ? 'block' : 'none';
});
// 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");
if (data.total > 0) {
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.";
} else if (data.status === "done") {
progressText.textContent = "Processing complete!";
} else if (data.status === "video_done") {
progressText.textContent = "Video compilation complete!";
videoResult.style.display = "block";
videoLink.href = "/output/timelapse.mp4";
videoLink.textContent = "Download Timelapse Video";
} else if (data.status.startsWith("error:")) {
progressText.textContent = data.status;
} 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 + "%";
progressText.textContent = percent + "% (" + data.completed + "/" + data.total + ")";
}
})
.catch(err => console.error('Error fetching progress:', err));
}
// Poll every 1 second
setInterval(fetchProgress, 1000);
// Form submission
document.getElementById('timelapseForm').addEventListener('submit', function() {
document.getElementById("videoResult").style.display = "none";
});
</script>
</body>
</html>
</html>

View file

@ -55,6 +55,44 @@ class ProcessConfig:
landmark_model_path: str = "shape_predictor_68_face_landmarks.dat"
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 initialize_worker(face_detect_model_path, landmark_model_path):
"""
Initializes the face detector and predictor in each worker process.
@ -64,7 +102,7 @@ def initialize_worker(face_detect_model_path, landmark_model_path):
face_predictor = dlib.shape_predictor(landmark_model_path)
def get_assets_with_person(api_key, base_url, person_id):
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.
@ -72,6 +110,8 @@ def get_assets_with_person(api_key, base_url, person_id):
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.
@ -93,6 +133,16 @@ def get_assets_with_person(api_key, base_url, person_id):
"withPeople": True,
"withStacked": True,
}
# Add date filters if provided
if date_from:
payload["dateFilter"] = payload.get("dateFilter", {})
payload["dateFilter"]["from"] = f"{date_from}T00:00:00.000Z"
if date_to:
payload["dateFilter"] = payload.get("dateFilter", {})
payload["dateFilter"]["to"] = 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:
@ -106,7 +156,6 @@ def get_assets_with_person(api_key, base_url, person_id):
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.
@ -343,7 +392,7 @@ def process_asset_wrapper(args):
return process_asset_worker(asset, config)
def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None):
def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None, date_from=None, date_to=None):
"""
Processes assets containing the person and saves aligned face images.
@ -354,12 +403,21 @@ def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None):
config (ProcessConfig): Configuration parameters.
max_workers (int): Number of worker processes.
progress_callback (callable, optional): A callback function for progress updates.
date_from (str, optional): Start date for filtering assets.
date_to (str, optional): End date for filtering assets.
Returns:
list: A list of file paths of the saved images.
"""
os.makedirs(config.output_folder, exist_ok=True)
assets = get_assets_with_person(config.api_key, config.base_url, config.person_id)
# Check if we need to stop processing
if progress_callback and hasattr(progress_callback, "__self__") and getattr(progress_callback.__self__, "status",
"") == "cancelled":
logger.info("Processing was cancelled.")
return []
assets = get_assets_with_person(config.api_key, config.base_url, config.person_id, date_from, date_to)
logger.info(f"Found {len(assets)} assets containing the person.")
total_assets = len(assets)
if progress_callback:
@ -375,6 +433,13 @@ def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None):
# Pack arguments for each asset
tasks = ((asset, config) for asset in assets)
for result in tqdm(executor.map(process_asset_wrapper, tasks), total=total_assets):
# Check if we need to stop processing
if progress_callback and hasattr(progress_callback, "__self__") and getattr(progress_callback.__self__,
"status", "") == "cancelled":
logger.info("Processing was cancelled.")
executor.shutdown(wait=False)
break
if result is not None:
processed_files.append(result)
if progress_callback: