diff --git a/backfill_stats.js b/backfill_stats.js
new file mode 100644
index 0000000..78b7a37
--- /dev/null
+++ b/backfill_stats.js
@@ -0,0 +1,112 @@
+
+import fs from 'fs';
+import path from 'path';
+import sharp from 'sharp';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const UPLOAD_DIR = process.env.PICUPPER_UPLOAD_DIR || path.join(__dirname, 'uploads');
+
+async function processDirectory(dir) {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+
+ // Check if we are in a leaf "day" directory (has images)
+ const images = entries.filter(e => e.isFile() && /\.(jpg|jpeg|png|webp)$/i.test(e.name) && !e.name.includes('_thumb'));
+
+ if (images.length > 0) {
+ console.log(`Processing directory: ${dir}`);
+ const statsFile = path.join(dir, 'stats.json');
+ let stats = [];
+
+ if (fs.existsSync(statsFile)) {
+ try {
+ stats = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
+ } catch (e) {
+ console.error(`Error reading ${statsFile}`, e.message);
+ }
+ }
+
+ // Create a set of existing filenames for fast lookup
+ const processedFiles = new Set(stats.map(s => s.filename));
+ let updated = false;
+
+ for (const img of images) {
+ if (processedFiles.has(img.name)) continue;
+
+ const imgPath = path.join(dir, img.name);
+ console.log(` Computing brightness for ${img.name}...`);
+
+ try {
+ const image = sharp(imgPath);
+ const s = await image.stats();
+ const brightness = Math.round((s.channels[0].mean + s.channels[1].mean + s.channels[2].mean) / 3);
+
+ // Try to extract timestamp from filename: {cameraId}_{timestamp}.{ext}
+ // Timestamp in filename is usually ISO like 2024-12-19T14-00-00-000Z but with - instead of :
+ // We need a valid ISO string for consistency.
+
+ // Filename format: {cameraId}_YYYY-MM-DDTHH-mm-ss-sssZ.{ext}
+ // extract YYYY-MM-DDTHH-mm-ss-sssZ
+ let timestamp = new Date().toISOString();
+ const match = img.name.match(/_(\d{4}-\d{2}-\d{2}T[\d-]+Z)\./);
+ if (match) {
+ // Replace - with : in time part for standard ISO parseability if needed,
+ // or just store as is if that's what we did in server.js.
+ // In server.js: new Date().toISOString().replace(/[:.]/g, '-') was used for filename.
+ // For the stats entry, server.js uses new Date().toISOString() (standard ISO with colons).
+
+ // Reconstruct standard ISO
+ // stored format: 2025-12-19T14-25-00-123Z
+ // target: 2025-12-19T14:25:00.123Z
+ const rawTs = match[1];
+ const parts = rawTs.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/);
+ if (parts) {
+ timestamp = `${parts[1]}-${parts[2]}-${parts[3]}T${parts[4]}:${parts[5]}:${parts[6]}.${parts[7]}Z`;
+ } else {
+ // Fallback for older formats if any
+ timestamp = rawTs;
+ }
+ } else {
+ // Fallback to file mtime
+ const fstats = fs.statSync(imgPath);
+ timestamp = fstats.mtime.toISOString();
+ }
+
+ stats.push({
+ timestamp: timestamp,
+ filename: img.name,
+ brightness: brightness
+ });
+ updated = true;
+ } catch (err) {
+ console.error(` Failed to process ${img.name}:`, err.message);
+ }
+ }
+
+ if (updated) {
+ // Sort by timestamp
+ stats.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
+ fs.writeFileSync(statsFile, JSON.stringify(stats, null, 2));
+ console.log(` Updated ${statsFile} with new entries.`);
+ } else {
+ console.log(` No new images to process.`);
+ }
+ }
+
+ // Recurse into subdirectories
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ await processDirectory(path.join(dir, entry.name));
+ }
+ }
+}
+
+// Start
+console.log(`Starting brightness backfill in ${UPLOAD_DIR}...`);
+processDirectory(UPLOAD_DIR).then(() => {
+ console.log('Done!');
+}).catch(err => {
+ console.error('Fatal error:', err);
+});
diff --git a/public/index.html b/public/index.html
index 187e4a0..165f021 100644
--- a/public/index.html
+++ b/public/index.html
@@ -173,6 +173,13 @@
Loading images...
Select a camera and date to view images
+
+
+
+
Brightness Levels
+
+
@@ -292,7 +299,7 @@
}
}
- async function loadImages() {
+ async function loadImages(isAutoUpdate = false) {
const cameraId = cameraSelect.value;
const dateStr = dateSelect.value; // YYYY-MM-DD
@@ -304,9 +311,12 @@
localStorage.setItem('lastCamera', cameraId);
localStorage.setItem('lastDate', dateStr);
- loader.style.display = 'block';
- grid.innerHTML = '';
- emptyMsg.style.display = 'none';
+ if (!isAutoUpdate) {
+ loader.style.display = 'block';
+ grid.innerHTML = '';
+ emptyMsg.style.display = 'none';
+ document.getElementById('chartContainer').style.display = 'none';
+ }
const [year, month, day] = dateStr.split('-');
@@ -314,21 +324,31 @@
const res = await fetch(`cameras/${cameraId}/${year}/${month}/${day}`);
const data = await res.json();
- loader.style.display = 'none';
+ if (!isAutoUpdate) {
+ loader.style.display = 'none';
+ }
if (data.images.length === 0) {
- emptyMsg.style.display = 'block';
+ if (!isAutoUpdate) emptyMsg.style.display = 'block';
return;
}
+ // For auto-update, assume we have data now, clear grid to rebuild
+ if (isAutoUpdate) {
+ grid.innerHTML = '';
+ }
+
// Group by hour
const hours = {};
data.images.forEach(img => {
let h = '00';
try {
- const match = img.timestamp.match(/(\d{2})[-:](\d{2})[-:](\d{2})/);
- if (match) {
- h = match[1];
+ // Parse UTC timestamp from filename and convert to local time
+ const parts = img.timestamp.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})[-:](\d{2})[-:](\d{2})/);
+ if (parts) {
+ const [_, y, m, d, hUtc, mUtc, sUtc] = parts;
+ const date = new Date(Date.UTC(y, m - 1, d, hUtc, mUtc, sUtc));
+ h = String(date.getHours()).padStart(2, '0');
} else {
const d = new Date(img.timestamp);
if (!isNaN(d.getTime())) h = String(d.getHours()).padStart(2, '0');
@@ -347,10 +367,11 @@
const card = document.createElement('div');
card.className = 'card';
- card.dataset.idx = 0; // Current viewed index
+ const lastIdx = group.length - 1;
+ card.dataset.idx = lastIdx; // Default to last (newest)
const thumb = document.createElement('img');
- thumb.src = group[0].thumbnailUrl;
+ thumb.src = group[lastIdx].thumbnailUrl;
const info = document.createElement('div');
info.className = 'card-info';
@@ -375,7 +396,12 @@
};
card.onmouseleave = () => {
- // Optional: could reset here
+ const lastIdx = group.length - 1;
+ if (card.dataset.idx != lastIdx) {
+ thumb.src = group[lastIdx].thumbnailUrl;
+ card.dataset.idx = lastIdx;
+ info.textContent = `${hour}:00 (${group.length} pics)`;
+ }
};
card.onclick = () => {
@@ -388,16 +414,116 @@
grid.appendChild(card);
});
+ // Draw brightness chart if stats exist
+ if (data.stats && data.stats.length > 0) {
+ document.getElementById('chartContainer').style.display = 'block';
+ drawChart(data.stats);
+ }
+
} catch (err) {
loader.style.display = 'none';
console.error(err);
grid.innerHTML = 'Error loading images
';
+ document.getElementById('chartContainer').style.display = 'none';
+ }
+ }
+
+ function drawChart(stats) {
+ const canvas = document.getElementById('brightnessChart');
+ const ctx = canvas.getContext('2d');
+
+ // Resize canvas to match display size
+ const rect = canvas.parentNode.getBoundingClientRect();
+ canvas.width = rect.width;
+ canvas.height = 200;
+
+ const width = canvas.width;
+ const height = canvas.height;
+ const padding = { top: 20, right: 20, bottom: 30, left: 40 };
+
+ ctx.clearRect(0, 0, width, height);
+
+ // Sort stats by timestamp just in case
+ stats.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
+
+ // Helper to parsing time to minutes from start of day (local time)
+ const getMinutes = (iso) => {
+ // Parse UCT timestamp to Date object
+ const parts = iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})[-:](\d{2})[-:](\d{2})/);
+ let d;
+ if (parts) {
+ const [_, y, m, day, hUtc, mUtc, sUtc] = parts;
+ d = new Date(Date.UTC(y, m - 1, day, hUtc, mUtc, sUtc));
+ } else {
+ d = new Date(iso);
+ }
+ return d.getHours() * 60 + d.getMinutes();
+ };
+
+ const points = stats.map(s => ({
+ x: getMinutes(s.timestamp),
+ y: s.brightness
+ }));
+
+ // Scales
+ const mapX = (minutes) => padding.left + (minutes / 1440) * (width - padding.left - padding.right);
+ const mapY = (val) => height - padding.bottom - (val / 255) * (height - padding.top - padding.bottom);
+
+ // Draw Axes
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 1;
+
+ // X-Axis
+ ctx.beginPath();
+ ctx.moveTo(padding.left, height - padding.bottom);
+ ctx.lineTo(width - padding.right, height - padding.bottom);
+ ctx.stroke();
+
+ // Y-Axis
+ ctx.beginPath();
+ ctx.moveTo(padding.left, padding.top);
+ ctx.lineTo(padding.left, height - padding.bottom);
+ ctx.stroke();
+
+ // Draw Line
+ ctx.strokeStyle = '#3b82f6';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+
+ if (points.length > 0) {
+ ctx.moveTo(mapX(points[0].x), mapY(points[0].y));
+ for (let i = 1; i < points.length; i++) {
+ ctx.lineTo(mapX(points[i].x), mapY(points[i].y));
+ }
+ }
+ ctx.stroke();
+
+ // Draw Labels (Time)
+ ctx.fillStyle = '#ccc';
+ ctx.font = '12px sans-serif';
+ ctx.textAlign = 'center';
+
+ for (let h = 0; h <= 24; h += 4) {
+ const x = mapX(h * 60);
+ ctx.fillText(`${h}:00`, x, height - 10);
+ }
+
+ // Draw Labels (Brightness)
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ for (let b = 0; b <= 255; b += 64) {
+ const y = mapY(b);
+ ctx.fillText(Math.round(b), padding.left - 5, y);
}
}
function getPrettyTime(ts) {
- const parts = ts.match(/(\d{2})[-:](\d{2})[-:](\d{2})/);
- if (parts) return `${parts[1]}:${parts[2]}:${parts[3]}`;
+ const parts = ts.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})[-:](\d{2})[-:](\d{2})/);
+ if (parts) {
+ const [_, y, m, d, hUtc, mUtc, sUtc] = parts;
+ const date = new Date(Date.UTC(y, m - 1, d, hUtc, mUtc, sUtc));
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
+ }
return ts;
}
@@ -424,6 +550,14 @@
if (state.selectedDate) loadImages();
});
+ // Auto-update every 60 seconds
+ setInterval(() => {
+ if (state.selectedCamera && state.selectedDate) {
+ // Only auto-update if we are looking at something
+ loadImages(true);
+ }
+ }, 60000);
+
// Start
init();
diff --git a/server.js b/server.js
index b3a9e52..7bc16d5 100644
--- a/server.js
+++ b/server.js
@@ -116,14 +116,43 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
// Generate thumbnail
try {
+ const image = sharp(req.file.path);
+ const metadata = await image.metadata(); // Get metadata first
+ const stats = await image.stats(); // Get stats (includes mean for channels)
+
+ // Calculate Average Brightness
+ // Simple average of R, G, B mean values (0-255)
+ const brightness = Math.round((stats.channels[0].mean + stats.channels[1].mean + stats.channels[2].mean) / 3);
+
const thumbnailFilename = req.file.filename.replace(path.extname(req.file.filename), '_thumb.avif');
const thumbnailPath = path.join(path.dirname(req.file.path), thumbnailFilename);
+ const dirPath = path.dirname(req.file.path);
- await sharp(req.file.path)
+ await image
.resize(320) // Resize to 320px width, auto height
.toFormat('avif')
.toFile(thumbnailPath);
+ // Update daily stats.json
+ const statsFile = path.join(dirPath, 'stats.json');
+ let dailyStats = [];
+ try {
+ if (fs.existsSync(statsFile)) {
+ dailyStats = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
+ }
+ } catch (e) { console.error('Error reading stats.json', e); }
+
+ dailyStats.push({
+ timestamp: new Date().toISOString(),
+ filename: req.file.filename,
+ brightness
+ });
+
+ // Write back stats
+ try {
+ fs.writeFileSync(statsFile, JSON.stringify(dailyStats, null, 2));
+ } catch (e) { console.error('Error writing stats.json', e); }
+
res.json({
success: true,
cameraId,
@@ -132,17 +161,18 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
thumbnail: thumbnailFilename,
thumbnailPath: thumbnailPath,
size: req.file.size,
+ brightness,
timestamp: new Date().toISOString()
});
} catch (error) {
- console.error('Thumbnail generation failed:', error);
- // Still return success for the upload even if thumbnail fails
+ console.error('Processing failed:', error);
+ // Still return success for the upload even if processing fails
res.json({
success: true,
cameraId,
filename: req.file.filename,
path: req.file.path,
- thumbnailError: error.message,
+ error: error.message,
size: req.file.size,
timestamp: new Date().toISOString()
});
@@ -378,7 +408,16 @@ app.get('/cameras/:cameraId/:year/:month/:day', async (req, res) => {
// Sort by filename (timestamp) descending
images.sort((a, b) => b.filename.localeCompare(a.filename));
- res.json({ images });
+ // Load stats if available
+ let stats = [];
+ try {
+ const statsFile = path.join(datePath, 'stats.json');
+ if (fs.existsSync(statsFile)) {
+ stats = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
+ }
+ } catch (e) { console.error('Error reading stats.json', e); }
+
+ res.json({ images, stats });
} catch (error) {
res.status(500).json({ error: 'Failed to list images', details: error.message });
}