Files

680 lines
25 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;
var videoPathInput = document.getElementById('video-path-input');
var existingSection = document.getElementById('existing-videos-section');
var videoList = document.getElementById('video-list');
var selectedExistingVideo = null;
var zoneSection = document.getElementById('zone-section');
var zoneFixedConfig = document.getElementById('zone-fixed-config');
var zonePresetSelect = document.getElementById('zone-preset-select');
var zoneRadios = document.querySelectorAll('input[name="zone_mode"]');
/* ------------------------------------------------
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;
// Clear existing video selection
selectedExistingVideo = null;
videoPathInput.value = '';
if (videoList) {
videoList.querySelectorAll('.video-item').forEach(function (el) {
el.classList.remove('selected');
el.querySelector('input[type="radio"]').checked = false;
});
}
// 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();
showZoneSection();
}
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();
});
});
// Format dropdown change — update card data attribute
modelGrid.querySelectorAll('.model-card-format select').forEach(function (select) {
select.addEventListener('change', function () {
var card = select.closest('.model-card');
if (card) {
card.dataset.selectedFormat = select.value;
}
});
});
// 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();
});
}
}
/* ------------------------------------------------
Existing Videos — fetch, render, select
------------------------------------------------ */
function loadExistingVideos() {
fetch('/api/videos')
.then(function (res) { return res.json(); })
.then(function (videos) {
if (!videos || videos.length === 0) return;
existingSection.style.display = '';
videoList.innerHTML = '';
videos.forEach(function (v) {
var item = document.createElement('label');
item.className = 'video-item';
item.innerHTML =
'<input type="radio" name="existing_video" class="video-item-radio" data-path="' + escapeHtml(v.path) + '" data-name="' + escapeHtml(v.filename) + '" data-size="' + v.size + '">' +
'<div class="video-item-info">' +
'<div class="video-item-name">' + escapeHtml(v.filename) + '</div>' +
'<div class="video-item-meta">' + formatBytes(v.size) + ' &middot; ' + formatRelativeTime(v.mtime) + '</div>' +
'</div>' +
'<button type="button" class="video-delete-btn" title="Delete video" data-filename="' + escapeHtml(v.filename) + '">&#128465;</button>';
var radio = item.querySelector('input[type="radio"]');
radio.addEventListener('change', function () {
// Deselect all other items
videoList.querySelectorAll('.video-item').forEach(function (el) {
el.classList.remove('selected');
});
if (radio.checked) {
item.classList.add('selected');
selectedExistingVideo = v.path;
videoPathInput.value = v.path;
// Show in file-info
fileName.textContent = v.filename;
fileSize.textContent = formatBytes(v.size);
fileDuration.textContent = '—';
fileInfo.classList.add('visible');
// Hide upload zone, clear file input
fileInput.value = '';
previewWrap.classList.remove('visible');
showZoneSection();
} else {
selectedExistingVideo = null;
videoPathInput.value = '';
fileInfo.classList.remove('visible');
}
updateSubmitState();
});
item.addEventListener('click', function (e) {
if (e.target.tagName === 'INPUT') return;
if (e.target.classList.contains('video-delete-btn')) return;
radio.checked = true;
radio.dispatchEvent(new Event('change'));
});
var deleteBtn = item.querySelector('.video-delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
if (!confirm('Delete "' + v.filename + '"?')) return;
fetch('/api/videos/' + encodeURIComponent(v.filename), { method: 'DELETE' })
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.status === 'deleted') {
item.remove();
if (!videoList.querySelector('.video-item')) {
existingSection.style.display = 'none';
}
}
});
});
}
videoList.appendChild(item);
});
})
.catch(function () {});
}
/* ------------------------------------------------
Zone Mode — show/hide, fetch presets
------------------------------------------------ */
function showZoneSection() {
if (zoneSection) zoneSection.style.display = '';
}
function initZoneMode() {
if (!zoneSection) return;
// Fetch presets from API
fetch('/api/zones')
.then(function (res) { return res.json(); })
.then(function (presets) {
if (!presets || presets.length === 0) {
var fixedRadio = zoneSection.querySelector('input[value="fixed"]');
if (fixedRadio) fixedRadio.closest('label').style.display = 'none';
return;
}
zonePresetSelect.innerHTML = '';
presets.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.name;
opt.textContent = p.name + (p.description ? ' — ' + p.description : '');
zonePresetSelect.appendChild(opt);
});
})
.catch(function () {});
zoneRadios.forEach(function (radio) {
radio.addEventListener('change', function () {
var isFixed = zoneSection.querySelector('input[value="fixed"]').checked;
if (isFixed) {
zoneFixedConfig.classList.add('visible');
} else {
zoneFixedConfig.classList.remove('visible');
}
});
});
}
/* ------------------------------------------------
Submit — enable/disable, loading state
------------------------------------------------ */
function initFormSubmit() {
form.addEventListener('submit', function (e) {
var checked = modelGrid.querySelectorAll('.model-card-check:checked');
if (checked.length >= 3) {
if (!confirm('Running ' + checked.length + ' models simultaneously may use significant GPU memory on Jetson. Continue?')) {
e.preventDefault();
return;
}
}
if (videoPathInput.value) {
form.action = '/upload/reuse';
} else {
form.action = '/upload';
}
submitBtn.disabled = true;
submitBtn.textContent = 'Starting...';
submitHint.textContent = 'Starting analysis...';
});
}
function updateSubmitState() {
var hasVideo = selectedExistingVideo || (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.';
if (selectedExistingVideo) {
fileInput.removeAttribute('required');
} else if (fileInput.files && fileInput.files.length === 0) {
fileInput.setAttribute('required', '');
}
}
/* ------------------------------------------------
Init
------------------------------------------------ */
initUploadZone();
initModelCards();
initFormSubmit();
initZoneMode();
loadExistingVideos();
// Poll active jobs
function checkActiveJobs() {
fetch('/api/jobs/active')
.then(function(res) { return res.json(); })
.then(function(data) {
var banner = document.getElementById('active-jobs-banner');
var text = document.getElementById('active-jobs-text');
if (banner && text) {
if (data.count > 0) {
banner.style.display = '';
text.textContent = data.count + ' job' + (data.count > 1 ? 's' : '') + ' in progress';
} else {
banner.style.display = 'none';
}
}
})
.catch(function() {});
}
checkActiveJobs();
setInterval(checkActiveJobs, 5000);
})();
function formatRelativeTime(timestamp) {
var now = Math.floor(Date.now() / 1000);
var diff = now - timestamp;
if (diff < 0) diff = 0;
if (diff < 60) return 'just now';
if (diff < 3600) {
var mins = Math.floor(diff / 60);
return mins + (mins === 1 ? ' minute ago' : ' minutes ago');
}
if (diff < 86400) {
var hrs = Math.floor(diff / 3600);
return hrs + (hrs === 1 ? ' hour ago' : ' hours ago');
}
var days = Math.floor(diff / 86400);
return days + (days === 1 ? ' day ago' : ' days ago');
}
/* ============================================================
Status Page — polling, live preview, result updates
============================================================ */
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
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 progressRegion = document.getElementById('progress-section');
var statusAnnouncer = document.getElementById('status-announcer');
var cancelBtn = document.getElementById('cancel-btn');
var terminalStates = ['COMPLETED', 'FAILED', 'CANCELLED'];
var pollInterval = null;
var consecutiveFailures = 0;
var connectionLostIndicator = null;
var lastStatus = initialStatus;
if (!progressFill) return;
var previewToggle = document.getElementById('preview-toggle');
var toggleText = document.getElementById('toggle-text');
var liveFps = document.getElementById('live-fps');
var liveCounts = document.getElementById('live-counts');
var liveLoading = document.getElementById('live-loading');
var liveUnloading = document.getElementById('live-unloading');
var liveNet = document.getElementById('live-net');
if (previewToggle) {
previewToggle.addEventListener('change', function() {
if (this.checked) {
livePreviewImg.style.display = 'block';
livePreviewImg.src = '/api/preview/' + jobId + '?t=' + Date.now();
if (previewPlaceholder) previewPlaceholder.style.display = '';
livePreviewSection.style.display = '';
if (toggleText) toggleText.textContent = 'ON';
} else {
livePreviewImg.src = '';
livePreviewImg.style.display = 'none';
if (previewPlaceholder) previewPlaceholder.style.display = '';
livePreviewSection.style.display = '';
if (toggleText) toggleText.textContent = 'OFF';
fetch('/api/jobs/' + jobId + '/preview', {
method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: false})
});
}
});
}
/* --- Cancel button --- */
if (cancelBtn) {
cancelBtn.addEventListener('click', function() {
if (!confirm('Abort this job?')) return;
cancelBtn.disabled = true;
cancelBtn.textContent = 'Cancelling...';
fetch('/api/jobs/' + jobId + '/cancel', { method: 'POST' })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.status === 'cancelled') {
cancelBtn.textContent = 'Cancelled';
cancelBtn.classList.add('cancelled');
} else {
cancelBtn.disabled = false;
cancelBtn.textContent = 'Abort';
if (data.error) alert(data.error);
}
})
.catch(function() {
cancelBtn.disabled = false;
cancelBtn.textContent = 'Abort';
});
});
}
/* --- 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);
announceStatusChange(data.status, data.progress);
if (data.live_fps !== undefined && liveFps) {
liveFps.textContent = data.live_fps > 0 ? data.live_fps.toFixed(1) + ' FPS' : 'Starting...';
}
if (data.live_loading !== undefined) {
if (liveCounts) liveCounts.style.display = '';
if (liveLoading) liveLoading.textContent = data.live_loading;
if (liveUnloading) liveUnloading.textContent = data.live_unloading;
if (liveNet) liveNet.textContent = (data.live_loading - data.live_unloading);
}
if (progressRegion) {
progressRegion.setAttribute('aria-busy', data.status === 'RUNNING' || data.status === 'PENDING' ? 'true' : 'false');
}
if (terminalStates.indexOf(data.status) !== -1) {
stopPolling();
}
})
.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 + '%';
var progressBar = progressFill.parentElement;
if (progressBar && progressBar.getAttribute('role') === 'progressbar') {
progressBar.setAttribute('aria-valuenow', 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');
if (cancelBtn) {
cancelBtn.style.display = (status === 'PENDING' || status === 'RUNNING') ? 'inline-flex' : 'none';
}
}
/* --- Update current model name --- */
function updateModelName(name) {
if (!modelName) return;
modelName.textContent = name || '—';
}
/* --- 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">Sack In</span></div>' +
'<div class="stat"><span class="stat-value stat-unloading">' + r.unloading_count + '</span><span class="stat-label">Sack Out</span></div>' +
'<div class="stat"><span class="stat-value stat-loading">' + (r.box_loading_count || 0) + '</span><span class="stat-label">Box In</span></div>' +
'<div class="stat"><span class="stat-value stat-unloading">' + (r.box_unloading_count || 0) + '</span><span class="stat-label">Box Out</span></div>' +
'<div class="stat"><span class="stat-value stat-net">' + r.net_count + '</span><span class="stat-label">Sack 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';
}
}
/* --- Announce status changes to screen readers --- */
function announceStatusChange(status, progress) {
if (!statusAnnouncer) return;
if (status === lastStatus) return;
lastStatus = status;
var pct = Math.min(100, Math.max(0, Math.round(progress * 100)));
var msg = 'Job status changed to ' + status;
if (status === 'RUNNING') msg += ', ' + pct + '% complete';
if (status === 'COMPLETED') msg += '. Results are ready.';
if (status === 'FAILED') msg += '. Job has failed.';
statusAnnouncer.textContent = msg;
}
/* --- Start / stop polling --- */
function startPolling() {
pollInterval = setInterval(pollJob, 2000);
}
function stopPolling() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
}
/* --- Delete button --- */
var deleteBtn = document.getElementById('delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', function() {
if (!confirm('Delete this job and all its output files?')) return;
deleteBtn.disabled = true;
deleteBtn.textContent = 'Deleting...';
fetch('/api/jobs/' + jobId, { method: 'DELETE' })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.status === 'deleted') {
window.location.href = '/jobs';
} else {
deleteBtn.disabled = false;
deleteBtn.textContent = 'Delete';
if (data.error) alert(data.error);
}
})
.catch(function() {
deleteBtn.disabled = false;
deleteBtn.textContent = 'Delete';
});
});
}
/* --- 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();
}
}