LOAD MP4 on an OUTPUT_DIR file copies it into UPLOAD_DIR (chunked, progress via /api/copy-progress) before spawning RECOUNT_CMD; collision in UPLOAD_DIR fails with 400. New read-only folder-grouped browser mirrors recounting_dashboard.py. Docs updated.
1110 lines
38 KiB
HTML
1110 lines
38 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{ site_name }} Recounting Upload</title>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
:root {
|
|
--bg: #000000;
|
|
--surface: #181818;
|
|
--surface2: #202020;
|
|
--gold: #FFC000;
|
|
--gold-text: #FFCE3E;
|
|
--white: #FFFFFF;
|
|
--ash: #7D7D7D;
|
|
--graphite: #494949;
|
|
--divider: #333333;
|
|
--success: #00ff88;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Helvetica Neue', 'Arial', sans-serif;
|
|
background: var(--bg);
|
|
color: var(--white);
|
|
min-height: 100vh;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1600px; margin: 0 auto; padding: 32px 40px;
|
|
}
|
|
|
|
/* Header */
|
|
.header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 20px 0; margin-bottom: 40px;
|
|
border-bottom: 1px solid var(--divider);
|
|
}
|
|
.header-left { display: flex; align-items: center; gap: 16px; }
|
|
.logo-icon {
|
|
width: 40px; height: 40px;
|
|
background: var(--gold); color: #000;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 20px; font-weight: 700;
|
|
}
|
|
.header h1 {
|
|
font-size: 16px; font-weight: 400; letter-spacing: 4px;
|
|
text-transform: uppercase; color: var(--white);
|
|
}
|
|
.header-sub { font-size: 9px; color: var(--ash); letter-spacing: 3px; text-transform: uppercase; }
|
|
.header-right { display: flex; align-items: center; gap: 24px; }
|
|
.live-chip {
|
|
display: flex; align-items: center; gap: 8px;
|
|
font-size: 9px; letter-spacing: 3px; padding: 6px 14px;
|
|
text-transform: uppercase; color: var(--white);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
}
|
|
.live-chip.live-chip-green { border-color: var(--success); color: var(--success); }
|
|
.live-chip .dot { width: 6px; height: 6px; }
|
|
.live-chip .dot { background: var(--white); }
|
|
.live-chip.live-chip-green .dot { background: var(--success); }
|
|
|
|
.section-title {
|
|
font-size: 11px; font-weight: 400; letter-spacing: 4px;
|
|
text-transform: uppercase; color: var(--ash);
|
|
margin-bottom: 20px; display: inline-block;
|
|
}
|
|
|
|
/* Counter panels */
|
|
.counters-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1px;
|
|
margin-bottom: 40px;
|
|
background: var(--divider);
|
|
}
|
|
.panel {
|
|
background: var(--surface);
|
|
padding: 32px;
|
|
}
|
|
.counter-card {
|
|
text-align: center;
|
|
padding: 20px 0;
|
|
}
|
|
.counter-card .counter-val {
|
|
font-size: 56px; font-weight: 300;
|
|
color: var(--white);
|
|
line-height: 1;
|
|
margin-bottom: 8px;
|
|
}
|
|
.counter-card.recount .counter-val { color: var(--white); }
|
|
.counter-card .counter-label {
|
|
font-size: 10px; color: var(--ash); text-transform: uppercase; letter-spacing: 3px;
|
|
}
|
|
.counter-card .counter-sub {
|
|
font-size: 9px; color: var(--graphite); margin-top: 8px;
|
|
}
|
|
.counter-card .counter-sub .batch-num {
|
|
font-size: 18px; font-weight: 600; color: var(--gold-text);
|
|
}
|
|
.counter-status {
|
|
font-size: 9px; letter-spacing: 2px; text-transform: uppercase;
|
|
margin-top: 12px; padding: 4px 12px; display: inline-block;
|
|
}
|
|
.counter-status.connected { color: var(--success); }
|
|
.counter-status.disconnected { color: var(--ash); }
|
|
|
|
/* Bottom row: video + upload/file panel */
|
|
.bottom-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1px;
|
|
margin-bottom: 40px;
|
|
background: var(--divider);
|
|
}
|
|
|
|
.video-panel {
|
|
background: var(--surface);
|
|
padding: 24px;
|
|
display: flex; flex-direction: column;
|
|
min-height: 380px;
|
|
}
|
|
.video-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
}
|
|
.video-container {
|
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
|
background: #000;
|
|
overflow: hidden; position: relative;
|
|
aspect-ratio: 4 / 3; max-height: 480px;
|
|
}
|
|
.video-container img {
|
|
width: 100%; height: 100%; object-fit: contain; display: block;
|
|
}
|
|
.video-placeholder {
|
|
text-align: center; color: var(--graphite); padding: 40px;
|
|
}
|
|
.video-placeholder .vp-icon { font-size: 40px; opacity: 0.2; margin-bottom: 12px; }
|
|
.video-placeholder .vp-text { font-size: 10px; letter-spacing: 2px; text-transform: uppercase; }
|
|
.video-placeholder .vp-hint { font-size: 8px; margin-top: 8px; color: var(--graphite); }
|
|
.video-status {
|
|
font-size: 8px; letter-spacing: 2px; text-transform: uppercase;
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.video-status.connected { color: var(--success); }
|
|
.video-status.stopped { color: var(--ash); }
|
|
.video-toggle {
|
|
background: none; border: 1px solid rgba(255,255,255,0.3);
|
|
padding: 5px 14px; cursor: pointer;
|
|
font-size: 9px; letter-spacing: 2px; text-transform: uppercase;
|
|
color: var(--ash); transition: border-color 0.3s, color 0.3s;
|
|
}
|
|
.video-toggle:hover { border-color: var(--white); color: var(--white); }
|
|
.video-toggle.active { border-color: var(--success); color: var(--success); }
|
|
|
|
/* Upload + file browser panel */
|
|
.file-panel {
|
|
background: var(--surface);
|
|
padding: 24px;
|
|
display: flex; flex-direction: column;
|
|
}
|
|
.file-panel .fp-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: 12px;
|
|
}
|
|
.upload-area {
|
|
border: 1px dashed var(--divider);
|
|
padding: 16px;
|
|
margin-bottom: 12px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: border-color 0.2s, background 0.2s;
|
|
}
|
|
.upload-area:hover { border-color: var(--gold); }
|
|
.upload-area.dragover {
|
|
border-color: var(--gold);
|
|
background: rgba(255,192,0,0.04);
|
|
}
|
|
.upload-area .upload-icon {
|
|
font-size: 28px; color: var(--ash); opacity: 0.4; margin-bottom: 6px;
|
|
}
|
|
.upload-area .upload-text {
|
|
font-size: 10px; color: var(--ash); letter-spacing: 2px; text-transform: uppercase;
|
|
}
|
|
.upload-area .upload-hint {
|
|
font-size: 8px; color: var(--graphite); margin-top: 4px;
|
|
text-transform: uppercase; letter-spacing: 1px;
|
|
}
|
|
.upload-row {
|
|
display: flex; align-items: center; gap: 8px;
|
|
margin-bottom: 14px;
|
|
}
|
|
.upload-row .file-name {
|
|
flex: 1; font-size: 9px; color: var(--ash);
|
|
word-break: break-all; padding: 6px 10px;
|
|
background: var(--surface2); border: 1px solid var(--divider);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.upload-row .file-name.has-file { color: var(--success); }
|
|
.file-list {
|
|
flex: 1; overflow-y: auto; max-height: 300px;
|
|
}
|
|
.output-panel {
|
|
background: var(--surface);
|
|
padding: 24px;
|
|
display: flex; flex-direction: column;
|
|
}
|
|
.output-panel .fp-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: 12px;
|
|
}
|
|
.output-list {
|
|
flex: 1; overflow-y: auto; max-height: 300px;
|
|
}
|
|
.folder-header {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 8px 12px; margin-bottom: 2px;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--divider);
|
|
cursor: pointer;
|
|
font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
|
|
color: var(--white);
|
|
transition: border-color 0.2s, background 0.2s;
|
|
}
|
|
.folder-header:hover { border-color: var(--gold); }
|
|
.folder-header .folder-arrow {
|
|
display: inline-block; color: var(--gold); font-size: 10px;
|
|
transition: transform 0.2s;
|
|
}
|
|
.folder-header.open .folder-arrow { transform: rotate(90deg); }
|
|
.folder-body { display: none; padding-left: 8px; }
|
|
.folder-body.open { display: block; }
|
|
.file-item {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 10px 12px; margin-bottom: 2px;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--divider);
|
|
cursor: pointer; transition: border-color 0.2s, background 0.2s;
|
|
}
|
|
.file-item:hover { border-color: var(--white); }
|
|
.file-item.selected {
|
|
border-color: var(--gold);
|
|
background: rgba(255,192,0,0.06);
|
|
}
|
|
.file-item.streaming {
|
|
border-color: var(--success);
|
|
background: rgba(0,255,136,0.06);
|
|
}
|
|
.file-item .fi-name {
|
|
font-size: 10px; color: var(--white);
|
|
word-break: break-all; flex: 1;
|
|
}
|
|
.file-item .fi-size {
|
|
font-size: 8px; color: var(--graphite);
|
|
margin-left: 8px; white-space: nowrap;
|
|
}
|
|
.file-item .fi-icon {
|
|
font-size: 12px; margin-right: 8px;
|
|
color: var(--ash);
|
|
}
|
|
.file-item.streaming .fi-icon { color: var(--success); }
|
|
|
|
.file-item .fi-delete {
|
|
background: none; border: 1px solid rgba(255,255,255,0.2);
|
|
color: var(--ash); cursor: pointer; padding: 3px 7px; margin-left: 6px;
|
|
font-size: 9px; transition: border-color 0.2s, color 0.2s;
|
|
}
|
|
.file-item .fi-delete:hover { border-color: var(--gold); color: var(--gold); }
|
|
|
|
.upload-status {
|
|
font-size: 9px; letter-spacing: 2px; text-transform: uppercase;
|
|
margin-top: 8px; text-align: center; color: var(--ash);
|
|
}
|
|
.upload-status.success { color: var(--success); }
|
|
.upload-status.error { color: var(--gold); }
|
|
|
|
.upload-progress {
|
|
display: none; width: 100%; height: 6px;
|
|
background: var(--surface2); border: 1px solid var(--divider);
|
|
margin-top: 10px; overflow: hidden;
|
|
}
|
|
.upload-progress .upload-progress-bar {
|
|
height: 100%; width: 0%;
|
|
background: var(--gold);
|
|
transition: width 0.15s ease;
|
|
}
|
|
.upload-progress-percent {
|
|
display: none; font-size: 8px; color: var(--graphite);
|
|
letter-spacing: 1px; text-align: center; margin-top: 4px;
|
|
}
|
|
|
|
/* Buttons */
|
|
.btn {
|
|
font-size: 9px; letter-spacing: 3px; text-transform: uppercase;
|
|
padding: 10px 20px; cursor: pointer; font-weight: 600; border: none;
|
|
transition: all 0.3s;
|
|
}
|
|
.btn-primary { background: var(--gold); color: #000; }
|
|
.btn-primary:hover { opacity: 0.85; }
|
|
.btn-danger { background: var(--white); color: #000; }
|
|
.btn-danger:hover { opacity: 0.85; }
|
|
.btn-ghost {
|
|
background: none; color: var(--ash);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
}
|
|
.btn-ghost:hover { border-color: var(--white); color: var(--white); }
|
|
.btn:disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
|
|
.btn-row { display: flex; gap: 8px; margin-top: 16px; }
|
|
|
|
/* Footer */
|
|
.footer {
|
|
margin-top: 40px; text-align: center;
|
|
font-size: 8px; letter-spacing: 4px; color: var(--graphite); text-transform: uppercase;
|
|
}
|
|
|
|
::-webkit-scrollbar { width: 4px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: var(--graphite); }
|
|
|
|
@media (max-width: 900px) {
|
|
.counters-row, .bottom-row { grid-template-columns: 1fr; }
|
|
.container { padding: 20px 12px; }
|
|
.header { flex-direction: column; gap: 12px; }
|
|
.counter-card .counter-val { font-size: 40px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="container">
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<div class="logo-icon">✖</div>
|
|
<div>
|
|
<h1>{{ site_name }} Recounting Upload</h1>
|
|
<div class="header-sub">Upload MP4 & recount</div>
|
|
</div>
|
|
</div>
|
|
<div class="header-right">
|
|
{% if live_dashboard_url %}<a class="live-chip live-chip-green" href="{{ live_dashboard_url }}" style="text-decoration:none;">LIVE DASHBOARD</a>{% endif %}
|
|
<div class="live-chip live-chip-green">
|
|
<span class="dot"></span>
|
|
<span>LIVE</span>
|
|
</div>
|
|
<div class="live-chip">
|
|
<span class="dot"></span>
|
|
<span>RECOUNT</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="counters-row">
|
|
<div class="panel">
|
|
<span class="section-title" id="live-title">Live Counter</span>
|
|
<div class="counter-card">
|
|
<div class="counter-val" id="live-count">--</div>
|
|
<div class="counter-label" id="live-label">Objects Counted</div>
|
|
<div class="counter-sub">Batch <span class="batch-num" id="live-batch">--</span> · <span id="live-time">--</span></div>
|
|
<div class="counter-status disconnected" id="live-status">
|
|
no file selected
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="panel">
|
|
<span class="section-title">Recount Progress</span>
|
|
<div class="counter-card recount">
|
|
<div class="counter-val" id="recount-count">--</div>
|
|
<div class="counter-label">Objects Recounted</div>
|
|
<div class="counter-sub"><span id="recount-time">--</span></div>
|
|
<div class="counter-status disconnected" id="recount-status">
|
|
waiting...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bottom-row">
|
|
<div class="video-panel">
|
|
<div class="video-header">
|
|
<span class="section-title" style="margin-bottom:0;">Preview</span>
|
|
<div style="display:flex;align-items:center;gap:10px;">
|
|
<span class="video-status stopped" id="video-status">
|
|
<span style="width:5px;height:5px;background:var(--ash);display:inline-block;"></span>
|
|
stopped
|
|
</span>
|
|
<button class="video-toggle" id="videoToggle" onclick="togglePreview()">STREAM OFF</button>
|
|
</div>
|
|
</div>
|
|
<div class="video-container" id="video-container">
|
|
<div class="video-placeholder" id="video-placeholder">
|
|
<div class="vp-icon">▶</div>
|
|
<div class="vp-text">Upload an MP4, then LOAD and START recount</div>
|
|
<div class="vp-hint">Preview via recount node live feed</div>
|
|
</div>
|
|
<img id="stream-img" alt="Stream" style="display:none;" />
|
|
</div>
|
|
</div>
|
|
<div class="file-panel">
|
|
<div class="fp-header">
|
|
<span class="section-title" style="margin-bottom:0;font-size:11px;">Upload MP4</span>
|
|
<button class="btn btn-ghost" onclick="loadUploads()" style="font-size:8px;padding:4px 8px;">
|
|
<i class="fas fa-sync-alt"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="upload-area" id="upload-drop" onclick="document.getElementById('upload-file-input').click()">
|
|
<div class="upload-icon">⇪</div>
|
|
<div class="upload-text">Click or drop .mp4 file here</div>
|
|
<div class="upload-hint">Max 512 MB</div>
|
|
</div>
|
|
|
|
<div class="upload-row">
|
|
<span class="file-name" id="pending-filename">no file chosen</span>
|
|
<button class="btn btn-ghost" id="btn-upload" onclick="uploadFile()">UPLOAD</button>
|
|
</div>
|
|
|
|
<div class="upload-progress" id="upload-progress">
|
|
<div class="upload-progress-bar" id="upload-progress-bar"></div>
|
|
</div>
|
|
<div class="upload-progress-percent" id="upload-progress-percent"></div>
|
|
<div class="upload-status" id="upload-status"></div>
|
|
|
|
<input type="file" id="upload-file-input" accept=".mp4" style="display:none"
|
|
onchange="onFilePicked(event)">
|
|
|
|
<div class="file-list" id="file-list">
|
|
<div style="font-size:10px;color:var(--graphite);text-align:center;padding:20px;text-transform:uppercase;letter-spacing:1px;">
|
|
No uploads yet
|
|
</div>
|
|
</div>
|
|
|
|
<div class="btn-row">
|
|
<button class="btn btn-ghost" id="btn-load" onclick="loadMp4()" disabled>
|
|
▷ LOAD MP4
|
|
</button>
|
|
<button class="btn btn-primary" id="btn-start" onclick="startRecount()" disabled>
|
|
▶ START RECOUNT
|
|
</button>
|
|
<button class="btn btn-danger" id="btn-stop" onclick="stopRecount()" disabled>
|
|
■ STOP
|
|
</button>
|
|
<button class="btn btn-ghost" id="btn-download" onclick="downloadFile()" disabled>
|
|
<i class="fas fa-download"></i>
|
|
</button>
|
|
<button class="btn btn-ghost" id="btn-delete" onclick="deleteUpload()" disabled>
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="output-panel">
|
|
<div class="fp-header">
|
|
<span class="section-title" style="margin-bottom:0;font-size:11px;">From Output Directory</span>
|
|
<button class="btn btn-ghost" onclick="loadOutputFiles()" style="font-size:8px;padding:4px 8px;">
|
|
<i class="fas fa-sync-alt"></i>
|
|
</button>
|
|
</div>
|
|
<div class="file-list output-list" id="output-list">
|
|
<div style="font-size:10px;color:var(--graphite);text-align:center;padding:20px;text-transform:uppercase;letter-spacing:1px;">
|
|
No files found
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">{{ site_name }} Recounting Upload Dashboard</div>
|
|
</div>
|
|
|
|
<script>
|
|
let selectedFile = null;
|
|
let streaming = false;
|
|
let streamUrl = '';
|
|
let previewAutoOn = false;
|
|
let loaded = false;
|
|
let started = false;
|
|
let pendingUploadFile = null;
|
|
let batchResultLoaded = false;
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadRecountProgress();
|
|
loadUploads();
|
|
loadOutputFiles();
|
|
syncState();
|
|
|
|
setInterval(loadRecountProgress, 500);
|
|
setInterval(loadUploads, 30000);
|
|
setInterval(loadOutputFiles, 30000);
|
|
setInterval(syncState, 1000);
|
|
|
|
var drop = document.getElementById('upload-drop');
|
|
drop.addEventListener('dragover', function(e) { e.preventDefault(); drop.classList.add('dragover'); });
|
|
drop.addEventListener('dragleave', function() { drop.classList.remove('dragover'); });
|
|
drop.addEventListener('drop', function(e) {
|
|
e.preventDefault();
|
|
drop.classList.remove('dragover');
|
|
if (e.dataTransfer.files.length > 0) {
|
|
setPendingFile(e.dataTransfer.files[0]);
|
|
}
|
|
});
|
|
});
|
|
|
|
function onFilePicked(event) {
|
|
if (event.target.files.length > 0) {
|
|
setPendingFile(event.target.files[0]);
|
|
}
|
|
event.target.value = '';
|
|
}
|
|
|
|
function setPendingFile(file) {
|
|
if (!file.name.toLowerCase().endsWith('.mp4')) {
|
|
showUploadStatus('Only .mp4 files allowed', 'error');
|
|
return;
|
|
}
|
|
pendingUploadFile = file;
|
|
document.getElementById('pending-filename').textContent = file.name;
|
|
document.getElementById('pending-filename').classList.add('has-file');
|
|
document.getElementById('upload-status').textContent = '';
|
|
document.getElementById('upload-status').className = 'upload-status';
|
|
showUploadProgress(false);
|
|
}
|
|
|
|
function showUploadProgress(show) {
|
|
document.getElementById('upload-progress').style.display = show ? 'block' : 'none';
|
|
document.getElementById('upload-progress-percent').style.display = show ? 'block' : 'none';
|
|
document.getElementById('upload-progress-bar').style.width = '0%';
|
|
document.getElementById('upload-progress-percent').textContent = '';
|
|
}
|
|
|
|
function showUploadStatus(msg, cls) {
|
|
var el = document.getElementById('upload-status');
|
|
el.textContent = msg;
|
|
el.className = 'upload-status ' + (cls || '');
|
|
}
|
|
|
|
function uploadFile() {
|
|
if (!pendingUploadFile) {
|
|
showUploadStatus('Pick a file first', 'error');
|
|
return;
|
|
}
|
|
var btn = document.getElementById('btn-upload');
|
|
btn.disabled = true;
|
|
btn.textContent = 'UPLOADING...';
|
|
showUploadStatus('Uploading...', '');
|
|
showUploadProgress(true);
|
|
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/api/upload');
|
|
|
|
xhr.upload.onprogress = function(e) {
|
|
if (e.lengthComputable) {
|
|
var pct = Math.round((e.loaded / e.total) * 100);
|
|
document.getElementById('upload-progress-bar').style.width = pct + '%';
|
|
document.getElementById('upload-progress-percent').textContent = pct + '%';
|
|
}
|
|
};
|
|
|
|
xhr.onload = function() {
|
|
showUploadProgress(false);
|
|
try {
|
|
var data = JSON.parse(xhr.responseText);
|
|
if (!data.success) {
|
|
showUploadStatus(data.error || 'Upload failed', 'error');
|
|
btn.disabled = false;
|
|
btn.textContent = 'UPLOAD';
|
|
return;
|
|
}
|
|
showUploadStatus('Upload complete: ' + data.name, 'success');
|
|
selectedFile = data.path;
|
|
renderButtons();
|
|
document.getElementById('pending-filename').textContent = 'no file chosen';
|
|
document.getElementById('pending-filename').classList.remove('has-file');
|
|
pendingUploadFile = null;
|
|
loadUploads();
|
|
loadBatchResult(data.path);
|
|
} catch (err) {
|
|
showUploadStatus('Upload error: ' + err.message, 'error');
|
|
}
|
|
btn.disabled = false;
|
|
btn.textContent = 'UPLOAD';
|
|
};
|
|
|
|
xhr.onerror = function() {
|
|
showUploadProgress(false);
|
|
showUploadStatus('Upload error: network error', 'error');
|
|
btn.disabled = false;
|
|
btn.textContent = 'UPLOAD';
|
|
};
|
|
|
|
xhr.onabort = function() {
|
|
showUploadProgress(false);
|
|
showUploadStatus('Upload aborted', '');
|
|
btn.disabled = false;
|
|
btn.textContent = 'UPLOAD';
|
|
};
|
|
|
|
var form = new FormData();
|
|
form.append('file', pendingUploadFile);
|
|
xhr.send(form);
|
|
}
|
|
|
|
function selectFile(path, name) {
|
|
if (selectedFile === path) {
|
|
selectedFile = null;
|
|
batchResultLoaded = false;
|
|
renderButtons();
|
|
document.getElementById('live-title').innerHTML = 'Live Counter';
|
|
document.getElementById('live-count').textContent = '--';
|
|
document.getElementById('live-label').textContent = 'Objects Counted';
|
|
document.getElementById('live-batch').textContent = '--';
|
|
document.getElementById('live-time').textContent = '--';
|
|
document.getElementById('live-status').textContent = 'no file selected';
|
|
document.getElementById('live-status').className = 'counter-status disconnected';
|
|
updateFileSelection();
|
|
return;
|
|
}
|
|
selectedFile = path;
|
|
renderButtons();
|
|
updateFileSelection();
|
|
loadBatchResult(path);
|
|
}
|
|
|
|
function updateFileSelection() {
|
|
document.querySelectorAll('.file-item').forEach(function(el) {
|
|
var p = el.getAttribute('data-path');
|
|
if (p === selectedFile) {
|
|
el.classList.add('selected');
|
|
} else {
|
|
el.classList.remove('selected');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadUploads() {
|
|
var container = document.getElementById('file-list');
|
|
try {
|
|
var res = await fetch('/api/uploads');
|
|
var files = await res.json();
|
|
if (!files || files.length === 0) {
|
|
container.innerHTML = '<div style="font-size:10px;color:var(--graphite);text-align:center;padding:20px;text-transform:uppercase;letter-spacing:1px;">No uploads yet</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = files.map(function(f) {
|
|
var sel = selectedFile === f.path;
|
|
var str = streaming && selectedFile === f.path;
|
|
var cls = 'file-item';
|
|
if (str) cls += ' streaming';
|
|
else if (sel) cls += ' selected';
|
|
return '<div class="' + cls + '" onclick="event.stopPropagation(); selectFile(\'' +
|
|
f.path.replace(/'/g, "\\'") + '\', \'' + f.name.replace(/'/g, "\\'") + '\')" data-path="' +
|
|
f.path.replace(/"/g, '"') + '">' +
|
|
'<span class="fi-icon">' + (str ? '◉' : '▶') + '</span>' +
|
|
'<span class="fi-name">' + f.name + '</span>' +
|
|
'<span class="fi-size">' + formatSize(f.size) + '</span>' +
|
|
'<button class="fi-delete" onclick="event.stopPropagation(); deleteUploadRecord(\'' +
|
|
f.path.replace(/'/g, "\\'") + '\')">✕</button>' +
|
|
'</div>';
|
|
}).join('');
|
|
} catch (err) {
|
|
console.error('Failed to load uploads:', err);
|
|
}
|
|
}
|
|
|
|
function toggleFolder(fid) {
|
|
var hdr = document.getElementById(fid + '-hdr');
|
|
var body = document.getElementById(fid);
|
|
if (hdr) hdr.classList.toggle('open');
|
|
if (body) body.classList.toggle('open');
|
|
}
|
|
|
|
async function loadOutputFiles() {
|
|
var container = document.getElementById('output-list');
|
|
try {
|
|
var res = await fetch('/api/mp4-files');
|
|
var folders = await res.json();
|
|
if (!folders || folders.length === 0) {
|
|
container.innerHTML = '<div style="font-size:10px;color:var(--graphite);text-align:center;padding:20px;text-transform:uppercase;letter-spacing:1px;">No MP4 files found</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = folders.map(function(g, gi) {
|
|
var fid = 'ofolder-' + gi;
|
|
return '<div class="folder-header" id="' + fid + '-hdr" onclick="toggleFolder(\'' + fid + '\')">' +
|
|
'<span class="folder-arrow">▶</span>' +
|
|
'<i class="fas fa-folder" style="color:var(--gold);"></i>' +
|
|
'<span>' + g.folder + '</span>' +
|
|
'<span style="font-size:8px;color:var(--graphite);">(' + g.files.length + ')</span>' +
|
|
'</div>' +
|
|
'<div class="folder-body" id="' + fid + '">' +
|
|
g.files.map(function(f) {
|
|
var sel = selectedFile === f.path;
|
|
var str = streaming && selectedFile === f.path;
|
|
var cls = 'file-item';
|
|
if (str) cls += ' streaming';
|
|
else if (sel) cls += ' selected';
|
|
return '<div class="' + cls + '" onclick="event.stopPropagation(); selectFile(\'' +
|
|
f.path.replace(/'/g, "\\'") + '\', \'' + f.name.replace(/'/g, "\\'") + '\')" data-path="' +
|
|
f.path.replace(/"/g, '"') + '">' +
|
|
'<span class="fi-icon">' + (str ? '◉' : '▶') + '</span>' +
|
|
'<span class="fi-name">' + f.name + '</span>' +
|
|
'<span class="fi-size">' + formatSize(f.size) + '</span>' +
|
|
'</div>';
|
|
}).join('') +
|
|
'</div>';
|
|
}).join('');
|
|
} catch (err) {
|
|
console.error('Failed to load output files:', err);
|
|
}
|
|
}
|
|
|
|
function formatSize(bytes) {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
return (bytes / 1073741824).toFixed(1) + ' GB';
|
|
}
|
|
|
|
function renderButtons() {
|
|
var btnLoad = document.getElementById('btn-load');
|
|
var btnStart = document.getElementById('btn-start');
|
|
var btnStop = document.getElementById('btn-stop');
|
|
var btnDownload = document.getElementById('btn-download');
|
|
var btnDelete = document.getElementById('btn-delete');
|
|
|
|
btnLoad.disabled = !(selectedFile && !loaded);
|
|
|
|
if (loaded && !started) {
|
|
btnStart.disabled = false;
|
|
btnStart.textContent = '\u25b6 START RECOUNT';
|
|
btnStart.classList.remove('btn-ghost');
|
|
} else if (started) {
|
|
btnStart.disabled = true;
|
|
btnStart.textContent = 'STREAMING';
|
|
btnStart.classList.add('btn-ghost');
|
|
} else {
|
|
btnStart.disabled = true;
|
|
btnStart.textContent = '\u25b6 START RECOUNT';
|
|
btnStart.classList.remove('btn-ghost');
|
|
}
|
|
|
|
btnStop.disabled = !streaming;
|
|
btnDownload.disabled = !selectedFile;
|
|
btnDelete.disabled = !selectedFile;
|
|
}
|
|
|
|
async function loadMp4() {
|
|
if (!selectedFile) return;
|
|
var btnLoad = document.getElementById('btn-load');
|
|
btnLoad.disabled = true;
|
|
btnLoad.textContent = 'LOADING...';
|
|
var pollTimer = null;
|
|
try {
|
|
var req = fetch('/api/load-mp4', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: selectedFile })
|
|
});
|
|
|
|
pollTimer = setInterval(async function() {
|
|
try {
|
|
var pr = await fetch('/api/copy-progress');
|
|
var p = await pr.json();
|
|
if (p.active) {
|
|
showCopyProgress(p.pct, p.done, p.total, p.name);
|
|
}
|
|
} catch (err) {}
|
|
}, 250);
|
|
|
|
var res = await req;
|
|
var data = await res.json();
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
hideCopyProgress();
|
|
if (!data.success) {
|
|
alert('Failed: ' + (data.error || 'unknown'));
|
|
renderButtons();
|
|
return;
|
|
}
|
|
streamUrl = data.stream_url;
|
|
selectedFile = data.path;
|
|
streaming = true;
|
|
loaded = true;
|
|
started = false;
|
|
showUploadStatus('LOADED ' + data.file + ' \u2014 press START RECOUNT', 'success');
|
|
renderButtons();
|
|
syncState();
|
|
} catch (err) {
|
|
if (pollTimer) clearInterval(pollTimer);
|
|
hideCopyProgress();
|
|
alert('Error: ' + err.message);
|
|
renderButtons();
|
|
} finally {
|
|
btnLoad.textContent = '\u25b6 LOAD MP4';
|
|
}
|
|
}
|
|
|
|
function showCopyProgress(pct, done, total, name) {
|
|
document.getElementById('upload-progress').style.display = 'block';
|
|
document.getElementById('upload-progress-percent').style.display = 'block';
|
|
document.getElementById('upload-progress-bar').style.width = pct + '%';
|
|
document.getElementById('upload-progress-percent').textContent = pct + '%';
|
|
document.getElementById('upload-status').textContent =
|
|
'Copying ' + name + ' \u2014 ' + formatSize(done) + ' / ' + formatSize(total);
|
|
}
|
|
|
|
function hideCopyProgress() {
|
|
document.getElementById('upload-progress').style.display = 'none';
|
|
document.getElementById('upload-progress-percent').style.display = 'none';
|
|
document.getElementById('upload-progress-bar').style.width = '0%';
|
|
document.getElementById('upload-progress-percent').textContent = '';
|
|
}
|
|
|
|
async function startRecount() {
|
|
if (!selectedFile || !loaded) return;
|
|
var btnStart = document.getElementById('btn-start');
|
|
btnStart.disabled = true;
|
|
btnStart.textContent = 'CONNECTING...';
|
|
|
|
lastRecountCount = null;
|
|
lastRecountTime = null;
|
|
document.getElementById('recount-count').textContent = '0';
|
|
document.getElementById('recount-time').textContent = 'resetting...';
|
|
document.getElementById('recount-status').textContent = 'resetting';
|
|
document.getElementById('recount-status').className = 'counter-status disconnected';
|
|
|
|
try {
|
|
var res = await fetch('/api/start-recount', {
|
|
method: 'POST'
|
|
});
|
|
var data = await res.json();
|
|
if (!data.success) {
|
|
alert('Failed: ' + (data.error || 'unknown'));
|
|
renderButtons();
|
|
return;
|
|
}
|
|
streamUrl = data.stream_url;
|
|
streaming = true;
|
|
selectedFile = data.path;
|
|
loaded = true;
|
|
started = true;
|
|
|
|
renderButtons();
|
|
document.getElementById('video-status').innerHTML =
|
|
'<span style="width:5px;height:5px;background:var(--success);display:inline-block;"></span> streaming ' + data.file;
|
|
document.getElementById('video-status').className = 'video-status connected';
|
|
enablePreview();
|
|
} catch (err) {
|
|
alert('Error: ' + err.message);
|
|
renderButtons();
|
|
}
|
|
}
|
|
|
|
async function stopRecount() {
|
|
try {
|
|
await fetch('/api/stop-recount', { method: 'POST' });
|
|
} catch (err) {
|
|
alert('Failed to stop recount');
|
|
return;
|
|
}
|
|
streaming = false;
|
|
streamUrl = '';
|
|
selectedFile = null;
|
|
previewAutoOn = false;
|
|
loaded = false;
|
|
started = false;
|
|
batchResultLoaded = false;
|
|
|
|
var img = document.getElementById('stream-img');
|
|
var placeholder = document.getElementById('video-placeholder');
|
|
img.src = '';
|
|
img.style.display = 'none';
|
|
if (placeholder) placeholder.style.display = '';
|
|
|
|
renderButtons();
|
|
|
|
document.getElementById('video-status').innerHTML =
|
|
'<span style="width:5px;height:5px;background:var(--ash);display:inline-block;"></span> stopped';
|
|
document.getElementById('video-status').className = 'video-status stopped';
|
|
|
|
document.getElementById('live-title').innerHTML = 'Live Counter';
|
|
document.getElementById('live-count').textContent = '--';
|
|
document.getElementById('live-label').textContent = 'Objects Counted';
|
|
document.getElementById('live-batch').textContent = '--';
|
|
document.getElementById('live-time').textContent = '--';
|
|
document.getElementById('live-status').textContent = 'no file selected';
|
|
document.getElementById('live-status').className = 'counter-status disconnected';
|
|
|
|
resetVideoToggle();
|
|
loadUploads();
|
|
}
|
|
|
|
function togglePreview() {
|
|
var btn = document.getElementById('videoToggle');
|
|
var img = document.getElementById('stream-img');
|
|
var placeholder = document.getElementById('video-placeholder');
|
|
if (!streamUrl) return;
|
|
if (img.style.display === 'block') {
|
|
img.src = '';
|
|
img.style.display = 'none';
|
|
if (placeholder) placeholder.style.display = '';
|
|
btn.textContent = 'STREAM OFF';
|
|
btn.classList.remove('active');
|
|
} else {
|
|
enablePreview();
|
|
}
|
|
}
|
|
|
|
function resetVideoToggle() {
|
|
var btn = document.getElementById('videoToggle');
|
|
var img = document.getElementById('stream-img');
|
|
var placeholder = document.getElementById('video-placeholder');
|
|
img.src = '';
|
|
img.style.display = 'none';
|
|
if (placeholder) placeholder.style.display = '';
|
|
btn.textContent = 'STREAM OFF';
|
|
btn.classList.remove('active');
|
|
}
|
|
|
|
function enablePreview() {
|
|
var btn = document.getElementById('videoToggle');
|
|
var img = document.getElementById('stream-img');
|
|
var placeholder = document.getElementById('video-placeholder');
|
|
if (!streamUrl) return;
|
|
previewAutoOn = true;
|
|
img.onerror = function() {
|
|
if (streaming && streamUrl) {
|
|
img.src = streamUrl + '?' + Date.now();
|
|
}
|
|
};
|
|
img.src = streamUrl + '?' + Date.now();
|
|
img.style.display = 'block';
|
|
if (placeholder) placeholder.style.display = 'none';
|
|
btn.textContent = 'STREAM ON';
|
|
btn.classList.add('active');
|
|
}
|
|
|
|
async function syncState() {
|
|
try {
|
|
var res = await fetch('/api/state');
|
|
var data = await res.json();
|
|
applyState(data);
|
|
} catch (err) {}
|
|
}
|
|
|
|
function applyState(data) {
|
|
if (!data) return;
|
|
|
|
if (data.streaming && data.file) {
|
|
streaming = true;
|
|
streamUrl = data.stream_url;
|
|
selectedFile = data.file;
|
|
loaded = true;
|
|
renderButtons();
|
|
document.getElementById('video-status').innerHTML =
|
|
'<span style="width:5px;height:5px;background:var(--success);display:inline-block;"></span> streaming ' + (data.file_name || '');
|
|
document.getElementById('video-status').className = 'video-status connected';
|
|
if (!previewAutoOn) enablePreview();
|
|
|
|
loadBatchResult(data.file);
|
|
} else if (data.file && selectedFile === data.file) {
|
|
previewAutoOn = false;
|
|
loaded = false;
|
|
started = false;
|
|
renderButtons();
|
|
loadBatchResult(data.file);
|
|
} else if (!data.streaming) {
|
|
previewAutoOn = false;
|
|
loaded = false;
|
|
started = false;
|
|
renderButtons();
|
|
}
|
|
}
|
|
|
|
async function loadBatchResult(filePath) {
|
|
if (!filePath) {
|
|
batchResultLoaded = false;
|
|
document.getElementById('live-title').innerHTML = 'Live Counter';
|
|
document.getElementById('live-label').textContent = 'Objects Counted';
|
|
document.getElementById('live-count').textContent = '--';
|
|
document.getElementById('live-batch').textContent = '--';
|
|
document.getElementById('live-time').textContent = '--';
|
|
document.getElementById('live-status').textContent = 'no file selected';
|
|
document.getElementById('live-status').className = 'counter-status disconnected';
|
|
return;
|
|
}
|
|
try {
|
|
var res = await fetch('/api/batch-result?file=' + encodeURIComponent(filePath));
|
|
var data = await res.json();
|
|
if (!data.success) {
|
|
document.getElementById('live-title').innerHTML = 'Live Counter';
|
|
document.getElementById('live-count').textContent = '--';
|
|
document.getElementById('live-label').textContent = 'Objects Counted';
|
|
document.getElementById('live-batch').textContent = '--';
|
|
document.getElementById('live-time').textContent = data.error || 'not found';
|
|
document.getElementById('live-status').textContent = 'not found';
|
|
document.getElementById('live-status').className = 'counter-status disconnected';
|
|
batchResultLoaded = false;
|
|
return;
|
|
}
|
|
batchResultLoaded = true;
|
|
document.getElementById('live-title').innerHTML = 'Batch Result';
|
|
document.getElementById('live-count').textContent = data.count.toLocaleString();
|
|
document.getElementById('live-label').textContent = 'Objects Counted';
|
|
document.getElementById('live-batch').textContent = data.batch_number;
|
|
document.getElementById('live-time').textContent = data.date;
|
|
var startStr = data.start_time ? new Date(data.start_time).toLocaleTimeString('en-US', {hour12: false, hour:'2-digit', minute:'2-digit'}) : '--';
|
|
var endStr = data.end_time ? new Date(data.end_time).toLocaleTimeString('en-US', {hour12: false, hour:'2-digit', minute:'2-digit'}) : '--';
|
|
document.getElementById('live-status').textContent = 'recorded \u00b7 ' + startStr + ' \u2013 ' + endStr;
|
|
document.getElementById('live-status').className = 'counter-status connected';
|
|
} catch (err) {
|
|
console.error('Failed to load batch result:', err);
|
|
batchResultLoaded = false;
|
|
}
|
|
}
|
|
|
|
async function deleteUploadRecord(fpath) {
|
|
var ok = confirm('Delete ' + (fpath.split('/').pop()) + '?');
|
|
if (!ok) return;
|
|
try {
|
|
var res = await fetch('/api/delete-upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: fpath })
|
|
});
|
|
var data = await res.json();
|
|
if (!data.success) {
|
|
alert('Delete failed: ' + (data.error || 'unknown'));
|
|
return;
|
|
}
|
|
if (selectedFile === fpath) {
|
|
selectedFile = null;
|
|
batchResultLoaded = false;
|
|
renderButtons();
|
|
document.getElementById('live-title').innerHTML = 'Live Counter';
|
|
document.getElementById('live-count').textContent = '--';
|
|
document.getElementById('live-label').textContent = 'Objects Counted';
|
|
document.getElementById('live-batch').textContent = '--';
|
|
document.getElementById('live-time').textContent = '--';
|
|
document.getElementById('live-status').textContent = 'no file selected';
|
|
document.getElementById('live-status').className = 'counter-status disconnected';
|
|
}
|
|
loadUploads();
|
|
} catch (err) {
|
|
alert('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function deleteUpload() {
|
|
if (!selectedFile) return;
|
|
deleteUploadRecord(selectedFile);
|
|
}
|
|
|
|
function downloadFile() {
|
|
if (!selectedFile) return;
|
|
var a = document.createElement('a');
|
|
a.href = '/api/download-mp4?file=' + encodeURIComponent(selectedFile);
|
|
a.download = selectedFile.split('/').pop();
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
|
|
var lastRecountCount = null;
|
|
var lastRecountTime = null;
|
|
|
|
async function loadRecountProgress() {
|
|
var countEl = document.getElementById('recount-count');
|
|
var timeEl = document.getElementById('recount-time');
|
|
var statusEl = document.getElementById('recount-status');
|
|
try {
|
|
var res = await fetch('/api/recount-progress');
|
|
var data = await res.json();
|
|
if (!data.success) {
|
|
if (lastRecountCount === null) {
|
|
countEl.textContent = '--';
|
|
timeEl.textContent = data.error || 'unreachable';
|
|
}
|
|
statusEl.className = 'counter-status disconnected';
|
|
statusEl.textContent = 'completed';
|
|
return;
|
|
}
|
|
lastRecountCount = data.count.toLocaleString();
|
|
countEl.textContent = lastRecountCount;
|
|
if (data.last_detection_time) {
|
|
var d = new Date(data.last_detection_time);
|
|
lastRecountTime = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
timeEl.textContent = lastRecountTime;
|
|
} else if (lastRecountTime) {
|
|
timeEl.textContent = lastRecountTime;
|
|
} else {
|
|
timeEl.textContent = 'waiting...';
|
|
}
|
|
statusEl.className = 'counter-status connected';
|
|
statusEl.textContent = 'connected';
|
|
} catch (err) {
|
|
if (lastRecountCount === null) {
|
|
countEl.textContent = '--';
|
|
timeEl.textContent = 'unreachable';
|
|
}
|
|
statusEl.className = 'counter-status disconnected';
|
|
statusEl.textContent = 'completed';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|