385 lines
13 KiB
JavaScript
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);
|
|
}
|
|
}
|