initial commit

This commit is contained in:
Jack
2026-08-30 05:16:30 +00:00
commit b9a1bb3e93
16 changed files with 720 additions and 0 deletions
Binary file not shown.
Binary file not shown.
+232
View File
@@ -0,0 +1,232 @@
from flask import Flask, render_template, request, jsonify, send_file, send_from_directory
from datetime import datetime
from pathlib import Path
import subprocess
import re
import os
from database import init_db, add_project, get_all_projects, get_project, update_last_capture, delete_project
from scheduler import scheduler, start_job, stop_job, start_all_jobs
app = Flask(__name__)
CAPTURES_DIR = Path('captures')
TIMELAPSES_DIR = Path('timelapses')
# Ensure directories exist
CAPTURES_DIR.mkdir(exist_ok=True)
TIMELAPSES_DIR.mkdir(exist_ok=True)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/api/projects', methods=['GET'])
def list_projects():
projects = get_all_projects()
result = []
for p in projects:
project_id, name, ip, port, period, username, password, created, last_capture, active = p
# Count images and get last image
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
capture_folder = CAPTURES_DIR / safe_name
image_count = 0
last_image = None
if capture_folder.exists():
images = sorted(capture_folder.glob('*.jpg'))
image_count = len(images)
if images:
last_image = f"/api/projects/{project_id}/image/latest"
# Calculate runtime
created_dt = datetime.strptime(created, '%Y-%m-%d %H:%M:%S')
runtime = datetime.now() - created_dt
result.append({
'id': project_id,
'name': name,
'ip_address': ip,
'port': port,
'period_minutes': period,
'created': created,
'runtime_hours': round(runtime.total_seconds() / 3600, 2),
'image_count': image_count,
'last_capture': last_capture,
'has_auth': bool(username and password),
'last_image': last_image
})
return jsonify(result)
@app.route('/api/projects', methods=['POST'])
def create_project():
data = request.json
name = data.get('name', '').strip()
ip_address = data.get('ip_address', '').strip()
port = data.get('port')
period_minutes = data.get('period_minutes')
username = data.get('username', '').strip() or None
password = data.get('password', '').strip() or None
# Basic validation
if not name or not ip_address or not port or not period_minutes:
return jsonify({'error': 'All required fields must be filled'}), 400
# Validate IP address format
ip_pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
if not re.match(ip_pattern, ip_address):
return jsonify({'error': 'Invalid IP address format'}), 400
# Validate port
if not (1 <= port <= 65535):
return jsonify({'error': 'Port must be between 1 and 65535'}), 400
# Create project in database
project_id = add_project(name, ip_address, port, period_minutes, username, password)
if not project_id:
return jsonify({'error': 'Project name already exists'}), 400
# Start scheduler job
start_job(project_id, ip_address, port, period_minutes, username, password, name)
return jsonify({'success': True, 'project_id': project_id})
@app.route('/api/projects/<int:project_id>', methods=['DELETE'])
def remove_project(project_id):
project = get_project(project_id)
if not project:
return jsonify({'error': 'Project not found'}), 404
stop_job(project_id)
delete_project(project_id)
return jsonify({'success': True})
@app.route('/api/projects/<int:project_id>/image/latest')
def get_latest_image(project_id):
"""Serve the latest captured image"""
project = get_project(project_id)
if not project:
return jsonify({'error': 'Project not found'}), 404
_, name, _, _, _, _, _, _, _, _ = project
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
capture_folder = CAPTURES_DIR / safe_name
if not capture_folder.exists():
return jsonify({'error': 'No images found'}), 404
images = sorted(capture_folder.glob('*.jpg'))
if not images:
return jsonify({'error': 'No images found'}), 404
return send_file(images[-1], mimetype='image/jpeg')
@app.route('/api/projects/<int:project_id>/gallery')
def get_gallery(project_id):
"""Get list of images for gallery (last 50)"""
project = get_project(project_id)
if not project:
return jsonify({'error': 'Project not found'}), 404
_, name, _, _, _, _, _, _, _, _ = project
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
capture_folder = CAPTURES_DIR / safe_name
if not capture_folder.exists():
return jsonify({'error': 'No images found'}), 404
images = sorted(capture_folder.glob('*.jpg'), reverse=True)[:50]
result = []
for img in images:
stat = img.stat()
result.append({
'filename': img.name,
'path': f"/api/projects/{project_id}/image/{img.name}",
'size': stat.st_size,
'created': datetime.fromtimestamp(stat.st_ctime).isoformat()
})
return jsonify(result)
@app.route('/api/projects/<int:project_id>/image/<filename>')
def get_image(project_id, filename):
"""Serve a specific image"""
project = get_project(project_id)
if not project:
return jsonify({'error': 'Project not found'}), 404
_, name, _, _, _, _, _, _, _, _ = project
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
capture_folder = CAPTURES_DIR / safe_name
return send_from_directory(str(capture_folder), filename, mimetype='image/jpeg')
@app.route('/api/projects/<int:project_id>/timelapse', methods=['POST'])
def create_timelapse(project_id):
project = get_project(project_id)
if not project:
return jsonify({'error': 'Project not found'}), 404
_, name, _, _, _, _, _, _, _, _ = project
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
capture_folder = CAPTURES_DIR / safe_name
if not capture_folder.exists():
return jsonify({'error': 'No captures found'}), 404
# Get all images sorted by name
images = sorted(capture_folder.glob('*.jpg'))
if len(images) < 2:
return jsonify({'error': 'Need at least 2 images to create timelapse'}), 400
# Create video filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
video_filename = f"{safe_name}_{timestamp}.mp4"
video_path = TIMELAPSES_DIR / video_filename
# Create image list file for ffmpeg
list_file = capture_folder / 'images.txt'
with open(list_file, 'w') as f:
for img in images:
f.write(f"file '{img.absolute()}'\n")
# Run ffmpeg to create timelapse
ffmpeg_cmd = [
'ffmpeg', '-y',
'-f', 'concat',
'-safe', '0',
'-i', str(list_file),
'-vf', 'fps=30',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
str(video_path)
]
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
if result.returncode != 0:
return jsonify({'error': f'ffmpeg failed: {result.stderr}'}), 500
# Send file for download
return send_file(
video_path,
mimetype='video/mp4',
as_attachment=True,
download_name=video_filename
)
if __name__ == '__main__':
init_db()
# Start scheduler
scheduler.start()
# Load existing projects and restart their jobs
projects = get_all_projects()
start_all_jobs(projects)
app.run(host='0.0.0.0', port=5800, debug=True, use_reloader=False)
+72
View File
@@ -0,0 +1,72 @@
import sqlite3
from datetime import datetime
DB_PATH = 'timelapse.db'
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
ip_address TEXT NOT NULL,
port INTEGER NOT NULL,
period_minutes INTEGER NOT NULL,
username TEXT,
password TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_capture TIMESTAMP,
active INTEGER DEFAULT 1
)
''')
conn.commit()
conn.close()
def add_project(name, ip_address, port, period_minutes, username=None, password=None):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
try:
c.execute('''
INSERT INTO projects (name, ip_address, port, period_minutes, username, password)
VALUES (?, ?, ?, ?, ?, ?)
''', (name, ip_address, port, period_minutes, username, password))
conn.commit()
return c.lastrowid
except sqlite3.IntegrityError:
return None
finally:
conn.close()
def get_all_projects():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT * FROM projects WHERE active = 1')
projects = c.fetchall()
conn.close()
return projects
def get_project(project_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT * FROM projects WHERE id = ?', (project_id,))
project = c.fetchone()
conn.close()
return project
def update_last_capture(project_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
UPDATE projects SET last_capture = CURRENT_TIMESTAMP
WHERE id = ?
''', (project_id,))
conn.commit()
conn.close()
def delete_project(project_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('UPDATE projects SET active = 0 WHERE id = ?', (project_id,))
conn.commit()
conn.close()
+2
View File
@@ -0,0 +1,2 @@
flask==3.0.0
apscheduler==3.10.4
+72
View File
@@ -0,0 +1,72 @@
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
import requests
from pathlib import Path
from datetime import datetime
import re
scheduler = BackgroundScheduler()
jobs = {}
def capture_image(project_id, ip_address, port, username, password, capture_folder):
"""Capture an image from the camera"""
try:
# Build URL with optional auth
if username and password:
url = f"http://{username}:{password}@{ip_address}:{port}/axis-cgi/jpg/image.cgi"
else:
url = f"http://{ip_address}:{port}/axis-cgi/jpg/image.cgi"
# Create timestamped filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', capture_folder)
folder_path = Path(f"captures/{safe_name}")
folder_path.mkdir(parents=True, exist_ok=True)
output_path = folder_path / f"{timestamp}.jpg"
# Fetch image
response = requests.get(url, timeout=10)
response.raise_for_status()
# Save image
with open(output_path, 'wb') as f:
f.write(response.content)
print(f"Captured image for project {project_id}: {output_path}")
return True
except Exception as e:
print(f"Failed to capture image for project {project_id}: {e}")
return False
def start_job(project_id, ip_address, port, period_minutes, username, password, capture_folder):
"""Start a scheduled capture job"""
# Remove existing job if any
stop_job(project_id)
# Add new job
job = scheduler.add_job(
capture_image,
trigger=IntervalTrigger(minutes=period_minutes),
id=f"project_{project_id}",
args=[project_id, ip_address, port, username, password, capture_folder],
replace_existing=True
)
jobs[project_id] = job
print(f"Started job for project {project_id} (every {period_minutes} minutes)")
def stop_job(project_id):
"""Stop a scheduled capture job"""
job_id = f"project_{project_id}"
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
print(f"Stopped job for project {project_id}")
if project_id in jobs:
del jobs[project_id]
def start_all_jobs(projects):
"""Start jobs for all active projects"""
for p in projects:
project_id, name, ip, port, period, username, password, created, last_capture, active = p
start_job(project_id, ip, port, period, username, password, name)
+342
View File
@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timelapse Camera Manager</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; }
h1 { margin-bottom: 20px; color: #333; }
.form-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
.form-row { display: flex; gap: 15px; flex-wrap: wrap; align-items: flex-end; }
.form-group { flex: 1; min-width: 120px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: #555; }
.form-group input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.btn { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; }
.btn-primary { background: #007bff; color: white; }
.btn-primary:hover { background: #0056b3; }
.btn-danger { background: #dc3545; color: white; }
.btn-danger:hover { background: #c82333; }
.btn-success { background: #28a745; color: white; }
.btn-success:hover { background: #218838; }
.btn-secondary { background: #6c757d; color: white; }
.btn-secondary:hover { background: #545b62; }
.btn-small { padding: 6px 12px; font-size: 12px; }
.table-card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 15px; text-align: left; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; font-weight: 600; color: #555; }
tr:hover { background: #f8f9fa; }
.status-badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.status-running { background: #d4edda; color: #155724; }
.empty-state { text-align: center; padding: 40px; color: #888; }
#message { padding: 15px; border-radius: 4px; margin-bottom: 20px; display: none; }
.message-success { background: #d4edda; color: #155724; }
.message-error { background: #f8d7da; color: #721c24; }
.auth-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; background: #e2e3e5; color: #383d41; margin-left: 5px; }
.last-image-thumb { width: 80px; height: 60px; object-fit: cover; border-radius: 4px; border: 1px solid #ddd; background: #f0f0f0; }
.no-image { width: 80px; height: 60px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 4px; color: #999; font-size: 11px; }
/* Gallery Modal */
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 1000; }
.modal-content { max-width: 90%; max-height: 90%; margin: 2% auto; position: relative; }
.modal-img { max-width: 100%; max-height: 85vh; display: block; margin: 0 auto; }
.modal-close { position: absolute; top: 20px; right: 30px; color: white; font-size: 40px; cursor: pointer; z-index: 1001; }
.modal-prev, .modal-next { position: absolute; top: 50%; transform: translateY(-50%); color: white; font-size: 40px; cursor: pointer; padding: 20px; user-select: none; }
.modal-prev { left: 20px; }
.modal-next { right: 20px; }
.modal-caption { text-align: center; color: white; padding: 10px; font-size: 14px; }
/* Gallery Grid */
.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; padding: 20px; }
.gallery-item { cursor: pointer; position: relative; aspect-ratio: 4/3; overflow: hidden; border-radius: 4px; background: #f0f0f0; }
.gallery-item img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.2s; }
.gallery-item:hover img { transform: scale(1.05); }
.gallery-item .overlay { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.7); color: white; padding: 5px; font-size: 11px; }
.gallery-container { display: none; background: white; border-radius: 8px; margin-top: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.gallery-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid #eee; }
.gallery-header h3 { margin: 0; }
.loading { text-align: center; padding: 40px; color: #888; }
</style>
</head>
<body>
<div class="container">
<h1>📷 Timelapse Camera Manager</h1>
<div id="message"></div>
<div class="form-card">
<div class="form-row">
<div class="form-group">
<label>Project Name *</label>
<input type="text" id="projectName" placeholder="My Camera">
</div>
<div class="form-group">
<label>IP Address *</label>
<input type="text" id="ipAddress" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>Port *</label>
<input type="number" id="port" placeholder="80" min="1" max="65535">
</div>
<div class="form-group">
<label>Period (min) *</label>
<input type="number" id="period" placeholder="5" min="1">
</div>
<div class="form-group">
<label>Username</label>
<input type="text" id="username" placeholder="root">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="password" placeholder="pass">
</div>
<button class="btn btn-primary" onclick="createProject()">Add Project</button>
</div>
</div>
<div class="table-card">
<table>
<thead>
<tr>
<th>Project</th>
<th>Camera</th>
<th>Period</th>
<th>Runtime</th>
<th>Last Image</th>
<th>Images</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="projectsTable">
<tr><td colspan="7" class="empty-state">No projects yet. Add one above!</td></tr>
</tbody>
</table>
</div>
<!-- Gallery Container -->
<div id="galleryContainer" class="gallery-container">
<div class="gallery-header">
<h3 id="galleryTitle">Gallery</h3>
<button class="btn btn-secondary btn-small" onclick="closeGallery()">Close Gallery</button>
</div>
<div id="galleryGrid" class="gallery-grid"></div>
</div>
</div>
<!-- Image Modal -->
<div id="imageModal" class="modal">
<span class="modal-close" onclick="closeModal()">&times;</span>
<span class="modal-prev" onclick="prevImage()">&#10094;</span>
<span class="modal-next" onclick="nextImage()">&#10095;</span>
<img class="modal-img" id="modalImg">
<div class="modal-caption" id="modalCaption"></div>
</div>
<script>
let currentGallery = [];
let currentIndex = 0;
let currentProjectName = '';
function showMessage(msg, type) {
const el = document.getElementById('message');
el.textContent = msg;
el.className = type === 'success' ? 'message-success' : 'message-error';
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 5000);
}
async function loadProjects() {
const res = await fetch('/api/projects');
const projects = await res.json();
const tbody = document.getElementById('projectsTable');
if (projects.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No projects yet. Add one above!</td></tr>';
return;
}
tbody.innerHTML = projects.map(p => `
<tr>
<td>
<strong>${p.name}</strong>
${p.has_auth ? '<span class="auth-badge">🔒 Auth</span>' : ''}
</td>
<td>${p.ip_address}:${p.port}</td>
<td>${p.period_minutes} min</td>
<td><span class="status-badge status-running">${p.runtime_hours} hrs</span></td>
<td>
${p.last_image
? `<img src="${p.last_image}" class="last-image-thumb" alt="Latest" onload="this.style.background='none'">`
: `<div class="no-image">No images</div>`
}
</td>
<td>${p.image_count}</td>
<td>
<button class="btn btn-success btn-small" onclick="createTimelapse(${p.id})">Timelapse</button>
<button class="btn btn-secondary btn-small" onclick="openGallery(${p.id}, '${p.name}')">Gallery</button>
<button class="btn btn-danger btn-small" onclick="deleteProject(${p.id})">Delete</button>
</td>
</tr>
`).join('');
}
async function createProject() {
const name = document.getElementById('projectName').value;
const ip_address = document.getElementById('ipAddress').value;
const port = parseInt(document.getElementById('port').value);
const period_minutes = parseInt(document.getElementById('period').value);
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, ip_address, port, period_minutes, username, password })
});
const data = await res.json();
if (res.ok) {
showMessage('Project created successfully!', 'success');
document.getElementById('projectName').value = '';
document.getElementById('ipAddress').value = '';
document.getElementById('port').value = '';
document.getElementById('period').value = '';
document.getElementById('username').value = '';
document.getElementById('password').value = '';
loadProjects();
} else {
showMessage(data.error || 'Failed to create project', 'error');
}
}
async function deleteProject(id) {
if (!confirm('Are you sure? This will stop captures and delete the project.')) return;
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (res.ok) {
showMessage('Project deleted', 'success');
loadProjects();
} else {
showMessage('Failed to delete project', 'error');
}
}
async function createTimelapse(id) {
showMessage('Creating timelapse... this may take a moment', 'success');
const res = await fetch(`/api/projects/${id}/timelapse`, { method: 'POST' });
if (res.ok) {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `timelapse_${id}.mp4`;
a.click();
window.URL.revokeObjectURL(url);
showMessage('Timelapse downloaded!', 'success');
} else {
const data = await res.json();
showMessage(data.error || 'Failed to create timelapse', 'error');
}
}
async function openGallery(projectId, projectName) {
currentProjectName = projectName;
document.getElementById('galleryTitle').textContent = `Gallery: ${projectName}`;
document.getElementById('galleryContainer').style.display = 'block';
document.getElementById('galleryGrid').innerHTML = '<div class="loading">Loading images...</div>';
// Scroll to gallery
document.getElementById('galleryContainer').scrollIntoView({ behavior: 'smooth' });
try {
const res = await fetch(`/api/projects/${projectId}/gallery`);
const images = await res.json();
if (images.error) {
document.getElementById('galleryGrid').innerHTML = `<div class="loading">${images.error}</div>`;
return;
}
currentGallery = images;
if (images.length === 0) {
document.getElementById('galleryGrid').innerHTML = '<div class="loading">No images yet</div>';
return;
}
// Lazy load gallery grid
document.getElementById('galleryGrid').innerHTML = images.map((img, idx) => `
<div class="gallery-item" onclick="showModal(${idx})">
<img data-src="${img.path}" alt="${img.filename}" loading="lazy">
<div class="overlay">${img.created.split('T')[0]}</div>
</div>
`).join('');
// Lazy load images in grid
document.querySelectorAll('.gallery-item img').forEach(img => {
img.src = img.dataset.src;
});
} catch (e) {
document.getElementById('galleryGrid').innerHTML = '<div class="loading">Failed to load gallery</div>';
}
}
function closeGallery() {
document.getElementById('galleryContainer').style.display = 'none';
currentGallery = [];
}
function showModal(index) {
if (currentGallery.length === 0) return;
currentIndex = index;
const img = currentGallery[index];
document.getElementById('modalImg').src = img.path;
document.getElementById('modalCaption').textContent = `${img.filename} - ${img.created.replace('T', ' ').split('.')[0]}`;
document.getElementById('imageModal').style.display = 'block';
}
function closeModal() {
document.getElementById('imageModal').style.display = 'none';
}
function prevImage() {
if (currentIndex > 0) {
showModal(currentIndex - 1);
}
}
function nextImage() {
if (currentIndex < currentGallery.length - 1) {
showModal(currentIndex + 1);
}
}
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (document.getElementById('imageModal').style.display === 'block') {
if (e.key === 'Escape') closeModal();
if (e.key === 'ArrowLeft') prevImage();
if (e.key === 'ArrowRight') nextImage();
}
});
// Load projects on page load
loadProjects();
// Refresh every 30 seconds
setInterval(loadProjects, 30000);
</script>
</body>
</html>
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.