Add api cred validation, cancel button, ffmpeg timelapse and date range
This commit is contained in:
parent
399b09960d
commit
23939cccdd
3 changed files with 392 additions and 53 deletions
96
main.py
96
main.py
|
|
@ -1,8 +1,9 @@
|
||||||
import os
|
import os
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import threading
|
import threading
|
||||||
from flask import Flask, request, render_template, jsonify
|
import subprocess
|
||||||
from timelapse import process_faces, ProcessConfig
|
from flask import Flask, request, render_template, jsonify, redirect, url_for
|
||||||
|
from timelapse import process_faces, ProcessConfig, validate_immich_connection
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
@ -20,6 +21,8 @@ AVAILABLE_CORES = multiprocessing.cpu_count()
|
||||||
|
|
||||||
# Global progress dictionary – only one job at a time is assumed here
|
# Global progress dictionary – only one job at a time is assumed here
|
||||||
progress_info = {"completed": 0, "total": 0, "status": "idle"}
|
progress_info = {"completed": 0, "total": 0, "status": "idle"}
|
||||||
|
# Global processing thread reference
|
||||||
|
processing_thread = None
|
||||||
|
|
||||||
|
|
||||||
def update_progress(current, total):
|
def update_progress(current, total):
|
||||||
|
|
@ -35,7 +38,8 @@ def update_progress(current, total):
|
||||||
progress_info["status"] = "running" if current < total else "done"
|
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.
|
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.
|
face_resolution_threshold (int): Minimum required face resolution.
|
||||||
pose_threshold (float): Maximum allowed head pose deviation.
|
pose_threshold (float): Maximum allowed head pose deviation.
|
||||||
max_workers (int): Number of concurrent worker processes.
|
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:
|
try:
|
||||||
progress_info["status"] = "running"
|
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,
|
face_detect_model_path=FACE_DETECT_MODEL,
|
||||||
landmark_model_path=LANDMARK_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:
|
except Exception as e:
|
||||||
progress_info["status"] = f"error: {e}"
|
progress_info["status"] = f"error: {e}"
|
||||||
else:
|
|
||||||
progress_info["status"] = "done"
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/progress")
|
@app.route("/progress")
|
||||||
|
|
@ -80,15 +108,45 @@ def progress():
|
||||||
return jsonify(progress_info)
|
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"])
|
@app.route("/", methods=["GET", "POST"])
|
||||||
def index():
|
def index():
|
||||||
"""
|
"""
|
||||||
Index route that displays the form and starts processing in a background thread on POST.
|
Index route that displays the form and starts processing in a background thread on POST.
|
||||||
"""
|
"""
|
||||||
|
global processing_thread
|
||||||
|
|
||||||
result = None
|
result = None
|
||||||
error = 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":
|
if request.method == "POST":
|
||||||
try:
|
try:
|
||||||
person_id = request.form["person_id"]
|
person_id = request.form["person_id"]
|
||||||
|
|
@ -96,22 +154,34 @@ def index():
|
||||||
resize_size = int(request.form.get("resize_size", 512))
|
resize_size = int(request.form.get("resize_size", 512))
|
||||||
face_resolution_threshold = int(request.form.get("face_resolution_threshold", 128))
|
face_resolution_threshold = int(request.form.get("face_resolution_threshold", 128))
|
||||||
pose_threshold = float(request.form.get("pose_threshold", 25))
|
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
|
# Reset progress info before starting
|
||||||
progress_info["completed"] = 0
|
progress_info["completed"] = 0
|
||||||
progress_info["total"] = 0
|
progress_info["total"] = 0
|
||||||
progress_info["status"] = "idle"
|
progress_info["status"] = "idle"
|
||||||
|
progress_info.pop("video_path", None)
|
||||||
|
|
||||||
# Start the processing in a background thread
|
# Start the processing in a background thread
|
||||||
threading.Thread(
|
processing_thread = threading.Thread(
|
||||||
target=background_process,
|
target=background_process,
|
||||||
args=(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers)
|
args=(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers,
|
||||||
).start()
|
date_from, date_to, compile_video, framerate)
|
||||||
|
)
|
||||||
|
processing_thread.start()
|
||||||
result = "Processing started. Please wait and watch the progress bar below."
|
result = "Processing started. Please wait and watch the progress bar below."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = f"Error processing request: {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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,26 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #333;
|
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 {
|
form {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
@ -32,6 +49,7 @@
|
||||||
}
|
}
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
|
input[type="date"],
|
||||||
select {
|
select {
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|
@ -40,7 +58,16 @@
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 14px;
|
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;
|
background: #28a745;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
|
|
@ -51,9 +78,27 @@
|
||||||
display: block;
|
display: block;
|
||||||
margin: 20px auto 0;
|
margin: 20px auto 0;
|
||||||
}
|
}
|
||||||
button:hover {
|
.button-primary:hover {
|
||||||
background: #218838;
|
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 {
|
.result, .error {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
|
@ -73,44 +118,114 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 25px;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Immich Selfie Timelapse Tool</h1>
|
<h1>Immich Selfie Timelapse Tool</h1>
|
||||||
|
|
||||||
|
<!-- Connection Status -->
|
||||||
|
<div id="connectionStatus" class="connection-status">
|
||||||
|
Checking Immich server connection...
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<p class="error">{{ error }}</p>
|
<p class="error">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post">
|
|
||||||
<label>
|
<form method="post" id="timelapseForm">
|
||||||
Person ID:
|
<div class="section-header">Person Selection</div>
|
||||||
<input type="text" name="person_id" required>
|
<div class="form-group">
|
||||||
</label>
|
<label>
|
||||||
<label>
|
Person ID:
|
||||||
Padding added around the face in percent:
|
<input type="text" name="person_id" required>
|
||||||
<input type="number" step="1" name="padding_percent" value="{{ request.form.padding_percent or 30 }}">
|
</label>
|
||||||
</label>
|
</div>
|
||||||
<label>
|
|
||||||
Output image size:
|
<div class="section-header">Date Range (Optional)</div>
|
||||||
<input type="number" name="resize_size" value="{{ request.form.resize_size or 512 }}">
|
<div class="flex-row">
|
||||||
</label>
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
Face resolution threshold:
|
From Date:
|
||||||
<input type="number" name="face_resolution_threshold" value="{{ request.form.face_resolution_threshold or 128 }}">
|
<input type="date" name="date_from">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
</div>
|
||||||
Max orientation of the face (degrees):
|
<div class="form-group">
|
||||||
<input type="number" step="0.1" name="pose_threshold" value="{{ request.form.pose_threshold or 25 }}">
|
<label>
|
||||||
</label>
|
To Date:
|
||||||
<label>
|
<input type="date" name="date_to">
|
||||||
Max Workers (CPU cores):
|
</label>
|
||||||
<select name="max_workers">
|
</div>
|
||||||
{% for i in max_workers_options %}
|
</div>
|
||||||
<option value="{{ i }}" {% if request.form.max_workers|default('1')|int == i %}selected{% endif %}>{{ i }}</option>
|
|
||||||
{% endfor %}
|
<div class="section-header">Face Processing Settings</div>
|
||||||
</select>
|
<div class="form-group">
|
||||||
</label>
|
<label>
|
||||||
<button type="submit">Generate Timelapse</button>
|
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>
|
</form>
|
||||||
{% if result %}
|
{% if result %}
|
||||||
<p class="result">{{ result }}</p>
|
<p class="result">{{ result }}</p>
|
||||||
|
|
@ -119,26 +234,115 @@
|
||||||
<div id="progressContainer">
|
<div id="progressContainer">
|
||||||
<progress id="progressBar" value="0" max="100"></progress>
|
<progress id="progressBar" value="0" max="100"></progress>
|
||||||
<p id="progressText">0%</p>
|
<p id="progressText">0%</p>
|
||||||
|
<p id="videoResult" style="display:none;">
|
||||||
|
<a id="videoLink" href="#" download>Download Video</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<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() {
|
function fetchProgress() {
|
||||||
fetch('/progress')
|
fetch('/progress')
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const progressBar = document.getElementById("progressBar");
|
const progressBar = document.getElementById("progressBar");
|
||||||
const progressText = document.getElementById("progressText");
|
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.max = data.total;
|
||||||
progressBar.value = data.completed;
|
progressBar.value = data.completed;
|
||||||
const percent = Math.floor((data.completed / data.total) * 100);
|
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));
|
.catch(err => console.error('Error fetching progress:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll every 1 second
|
// Poll every 1 second
|
||||||
setInterval(fetchProgress, 1000);
|
setInterval(fetchProgress, 1000);
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
document.getElementById('timelapseForm').addEventListener('submit', function() {
|
||||||
|
document.getElementById("videoResult").style.display = "none";
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
73
timelapse.py
73
timelapse.py
|
|
@ -55,6 +55,44 @@ class ProcessConfig:
|
||||||
landmark_model_path: str = "shape_predictor_68_face_landmarks.dat"
|
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):
|
def initialize_worker(face_detect_model_path, landmark_model_path):
|
||||||
"""
|
"""
|
||||||
Initializes the face detector and predictor in each worker process.
|
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)
|
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.
|
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.
|
api_key (str): API key for authentication.
|
||||||
base_url (str): Base URL of the API.
|
base_url (str): Base URL of the API.
|
||||||
person_id (str): ID of the person to search for.
|
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:
|
Returns:
|
||||||
list: List of asset dictionaries.
|
list: List of asset dictionaries.
|
||||||
|
|
@ -93,6 +133,16 @@ def get_assets_with_person(api_key, base_url, person_id):
|
||||||
"withPeople": True,
|
"withPeople": True,
|
||||||
"withStacked": 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:
|
while payload["page"] is not None:
|
||||||
response = requests.post(url, headers=headers, json=payload)
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
if response.status_code != 200:
|
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')
|
payload["page"] = data['assets'].get('nextPage')
|
||||||
return all_assets
|
return all_assets
|
||||||
|
|
||||||
|
|
||||||
def download_asset(api_key, base_url, asset_id):
|
def download_asset(api_key, base_url, asset_id):
|
||||||
"""
|
"""
|
||||||
Downloads the original image asset from the API.
|
Downloads the original image asset from the API.
|
||||||
|
|
@ -343,7 +392,7 @@ def process_asset_wrapper(args):
|
||||||
return process_asset_worker(asset, config)
|
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.
|
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.
|
config (ProcessConfig): Configuration parameters.
|
||||||
max_workers (int): Number of worker processes.
|
max_workers (int): Number of worker processes.
|
||||||
progress_callback (callable, optional): A callback function for progress updates.
|
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:
|
Returns:
|
||||||
list: A list of file paths of the saved images.
|
list: A list of file paths of the saved images.
|
||||||
"""
|
"""
|
||||||
os.makedirs(config.output_folder, exist_ok=True)
|
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.")
|
logger.info(f"Found {len(assets)} assets containing the person.")
|
||||||
total_assets = len(assets)
|
total_assets = len(assets)
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
|
|
@ -375,6 +433,13 @@ def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None):
|
||||||
# Pack arguments for each asset
|
# Pack arguments for each asset
|
||||||
tasks = ((asset, config) for asset in assets)
|
tasks = ((asset, config) for asset in assets)
|
||||||
for result in tqdm(executor.map(process_asset_wrapper, tasks), total=total_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:
|
if result is not None:
|
||||||
processed_files.append(result)
|
processed_files.append(result)
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue