Files
feedmill-recounter/static/app.js
T

385 lines
13 KiB
JavaScript

/**
* Feedmill Recounter — Upload page interactive features
* Progressive enhancement: form works without JS (basic file input).
*/
(function () {
'use strict';
const form = document.getElementById('upload-form');
const zone = document.getElementById('upload-zone');
const fileInput = document.getElementById('video-input');
const previewWrap = document.getElementById('video-preview');
const previewVideo = document.getElementById('preview-video');
const fileInfo = document.getElementById('file-info');
const fileName = document.getElementById('file-name');
const fileSize = document.getElementById('file-size');
const fileDuration = document.getElementById('file-duration');
const submitBtn = document.getElementById('submit-btn');
const submitHint = document.getElementById('submit-hint');
const selectAllBtn = document.getElementById('select-all-btn');
const deselectAllBtn = document.getElementById('deselect-all-btn');
const modelGrid = document.getElementById('model-grid');
if (!form || !zone || !fileInput) return;
/* ------------------------------------------------
Upload Zone — click, drag-drop, keyboard
------------------------------------------------ */
function initUploadZone() {
// Click anywhere in zone (including the hidden input) triggers browse
zone.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput.click();
}
});
// Drag events
['dragenter', 'dragover'].forEach(function (evt) {
zone.addEventListener(evt, function (e) {
e.preventDefault();
e.stopPropagation();
zone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(function (evt) {
zone.addEventListener(evt, function (e) {
e.preventDefault();
e.stopPropagation();
zone.classList.remove('dragover');
});
});
zone.addEventListener('drop', function (e) {
var files = e.dataTransfer.files;
if (files.length > 0 && files[0].type.startsWith('video/')) {
fileInput.files = files;
handleFile(files[0]);
}
});
// File input change
fileInput.addEventListener('change', function () {
if (fileInput.files.length > 0) {
handleFile(fileInput.files[0]);
}
});
}
/* ------------------------------------------------
Video Preview & File Info
------------------------------------------------ */
function handleFile(file) {
if (!file || !file.type.startsWith('video/')) return;
// Show file info
fileName.textContent = file.name;
fileSize.textContent = formatBytes(file.size);
fileDuration.textContent = 'probing...';
fileInfo.classList.add('visible');
// Video preview via FileReader
var reader = new FileReader();
reader.onerror = function () {
fileDuration.textContent = 'unknown';
};
reader.onload = function (e) {
previewVideo.src = e.target.result;
previewVideo.load();
previewWrap.classList.add('visible');
previewVideo.onloadedmetadata = function () {
fileDuration.textContent = formatDuration(previewVideo.duration);
};
previewVideo.onerror = function () {
fileDuration.textContent = 'unknown';
};
};
reader.readAsDataURL(file);
updateSubmitState();
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
var k = 1024;
var sizes = ['B', 'KB', 'MB', 'GB'];
var i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatDuration(seconds) {
if (!isFinite(seconds)) return 'unknown';
var m = Math.floor(seconds / 60);
var s = Math.floor(seconds % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
}
/* ------------------------------------------------
Model Cards — select/deselect, highlight
------------------------------------------------ */
function initModelCards() {
if (!modelGrid) return;
var cards = modelGrid.querySelectorAll('.model-card');
cards.forEach(function (card) {
var checkbox = card.querySelector('.model-card-check');
// <label> auto-toggles the checkbox on click; just sync the UI
card.addEventListener('click', function (e) {
if (e.target.tagName === 'SELECT') return;
requestAnimationFrame(function () {
card.classList.toggle('selected', checkbox.checked);
updateSubmitState();
});
});
checkbox.addEventListener('change', function () {
card.classList.toggle('selected', checkbox.checked);
updateSubmitState();
});
});
// Select All / Deselect All
if (selectAllBtn) {
selectAllBtn.addEventListener('click', function () {
cards.forEach(function (card) {
var cb = card.querySelector('.model-card-check');
cb.checked = true;
card.classList.add('selected');
});
updateSubmitState();
});
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener('click', function () {
cards.forEach(function (card) {
var cb = card.querySelector('.model-card-check');
cb.checked = false;
card.classList.remove('selected');
});
updateSubmitState();
});
}
}
/* ------------------------------------------------
Submit — enable/disable, loading state
------------------------------------------------ */
function initFormSubmit() {
form.addEventListener('submit', function () {
submitBtn.disabled = true;
submitBtn.textContent = 'Starting...';
submitHint.textContent = 'Uploading and starting analysis...';
});
}
function updateSubmitState() {
var hasVideo = fileInput.files && fileInput.files.length > 0;
var hasModel = modelGrid && modelGrid.querySelector('.model-card-check:checked');
var enabled = hasVideo && !!hasModel;
submitBtn.disabled = !enabled;
submitHint.textContent = enabled
? 'Ready to start analysis.'
: 'Select a video and at least one model to begin.';
}
/* ------------------------------------------------
Init
------------------------------------------------ */
initUploadZone();
initModelCards();
initFormSubmit();
})();
/* ============================================================
Status Page — polling, live preview, result updates
============================================================ */
function initStatusPage(jobId, initialStatus) {
var statusBadge = document.getElementById('status-badge');
var progressFill = document.getElementById('progress-fill');
var progressPct = document.getElementById('progress-pct');
var modelName = document.getElementById('model-name');
var livePreviewImg = document.getElementById('live-preview-img');
var previewPlaceholder = document.getElementById('preview-placeholder');
var livePreviewSection = document.getElementById('live-preview-section');
var resultsSection = document.getElementById('results-section');
var resultsGrid = document.getElementById('results-grid');
var errorAlert = document.getElementById('error-alert');
var errorMessage = document.getElementById('error-message');
var videoThumb = document.getElementById('video-thumb');
var videoFallback = document.getElementById('video-fallback');
var terminalStates = ['COMPLETED', 'FAILED', 'CANCELLED'];
var pollInterval = null;
var previewInterval = null;
var consecutiveFailures = 0;
var connectionLostIndicator = null;
if (!progressFill) return;
/* --- Poll job status every 2s --- */
function pollJob() {
fetch('/api/jobs/' + jobId)
.then(function (res) { return res.json(); })
.then(function (data) {
consecutiveFailures = 0;
if (connectionLostIndicator) { connectionLostIndicator.style.display = 'none'; connectionLostIndicator = null; }
updateProgressBar(data.progress || 0);
updateStatusBadge(data.status);
updateModelName(data.current_model);
updateResults(data.results);
updateError(data.error);
if (data.status === 'RUNNING' || data.status === 'PENDING') {
updateLivePreview(jobId);
}
if (terminalStates.indexOf(data.status) !== -1) {
stopPolling();
if (data.status !== 'COMPLETED') {
updateLivePreview(jobId);
}
}
})
.catch(function () {
consecutiveFailures++;
if (consecutiveFailures >= 5 && !connectionLostIndicator) {
connectionLostIndicator = document.createElement('div');
connectionLostIndicator.className = 'card';
connectionLostIndicator.style.cssText = 'padding:0.75rem 1rem;color:#b91c1c;background:#fef2f2;border-left:3px solid #b91c1c;font-size:0.875rem;';
connectionLostIndicator.textContent = 'Connection lost. Retrying...';
progressFill.closest('.card').parentNode.insertBefore(connectionLostIndicator, progressFill.closest('.card').nextSibling);
}
});
}
/* --- Update progress bar --- */
function updateProgressBar(progress) {
var pct = Math.min(100, Math.max(0, Math.round(progress * 100)));
progressFill.style.width = pct + '%';
if (progressPct) progressPct.textContent = pct + '%';
}
/* --- Update status badge --- */
function updateStatusBadge(status) {
if (!statusBadge) return;
statusBadge.textContent = status;
statusBadge.className = 'status-badge badge';
var colorMap = {
'PENDING': 'badge-warning',
'RUNNING': 'badge-info',
'COMPLETED': 'badge-success',
'FAILED': 'badge-error',
'CANCELLED': 'badge-neutral'
};
statusBadge.classList.add(colorMap[status] || 'badge-neutral');
}
/* --- Update current model name --- */
function updateModelName(name) {
if (!modelName) return;
modelName.textContent = name || '—';
}
/* --- Fetch and display latest annotated frame --- */
function updateLivePreview(jid) {
if (!livePreviewImg) return;
livePreviewImg.src = '/api/jobs/' + jid + '/frame?t=' + Date.now();
livePreviewImg.onload = function () {
if (previewPlaceholder) previewPlaceholder.style.display = 'none';
livePreviewImg.style.display = 'block';
};
livePreviewImg.onerror = function () {
livePreviewImg.style.display = 'none';
if (previewPlaceholder) previewPlaceholder.style.display = '';
};
}
/* --- Render result cards --- */
function updateResults(results) {
if (!resultsGrid) return;
if (!results || results.length === 0) {
if (resultsSection) resultsSection.style.display = 'none';
return;
}
if (resultsSection) resultsSection.style.display = '';
resultsGrid.innerHTML = '';
results.forEach(function (r) {
var card = document.createElement('div');
card.className = 'result-card card';
card.innerHTML =
'<div class="result-model">' + escapeHtml(r.model_name) + '</div>' +
'<div class="result-stats">' +
'<div class="stat"><span class="stat-value stat-loading">' + r.loading_count + '</span><span class="stat-label">Loading</span></div>' +
'<div class="stat"><span class="stat-value stat-unloading">' + r.unloading_count + '</span><span class="stat-label">Unloading</span></div>' +
'<div class="stat"><span class="stat-value stat-net">' + r.net_count + '</span><span class="stat-label">Net</span></div>' +
'</div>' +
'<div class="result-meta">' +
'<span class="meta-item"><strong>Batches:</strong> ' + r.batch_count + '</span>' +
'<span class="meta-item"><strong>Frames:</strong> ' + r.frame_count + '</span>' +
'<span class="meta-item"><strong>Duration:</strong> ' + (r.duration_seconds || 0).toFixed(1) + 's</span>' +
'</div>';
if (r.output_path) {
var filename = r.output_path.split('/').pop();
card.innerHTML += '<a href="/download/' + jobId + '/' + escapeHtml(filename) + '" class="btn btn-primary result-download">Download</a>';
}
resultsGrid.appendChild(card);
});
}
/* --- Show error if present --- */
function updateError(err) {
if (!errorAlert) return;
if (err) {
errorAlert.style.display = '';
if (errorMessage) errorMessage.textContent = err;
} else {
errorAlert.style.display = 'none';
}
}
/* --- Escape HTML for safe insertion --- */
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/* --- Start / stop polling --- */
function startPolling() {
pollInterval = setInterval(pollJob, 2000);
previewInterval = setInterval(function () {
updateLivePreview(jobId);
}, 2000);
}
function stopPolling() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
if (previewInterval) { clearInterval(previewInterval); previewInterval = null; }
}
/* --- Boot --- */
updateProgressBar(0);
if (videoThumb) {
videoThumb.src = '/api/jobs/' + jobId + '/frame?t=0';
videoThumb.onloadeddata = function () {
videoThumb.style.display = 'block';
if (videoFallback) videoFallback.style.display = 'none';
};
videoThumb.onerror = function () {
videoThumb.style.display = 'none';
if (videoFallback) videoFallback.style.display = '';
};
}
if (terminalStates.indexOf(initialStatus) === -1) {
startPolling();
updateLivePreview(jobId);
} else {
updateLivePreview(jobId);
}
}