Add recounting upload dashboard with MP4 upload before recount
This commit is contained in:
1 parent
f6a388cdb9
commit
bcceaae626
3 files changed
+1304
No files matched your search
@@ -177,3 +177,14 @@ GO2RTC_URL=http://localhost:1984
|
||||
RTSP_PORT=8554
|
||||
# Stream name pushed to RTSP and consumed from go2rtc
|
||||
RECOUNT_STREAM_NAME=stream_1
|
||||
|
||||
# --- Recounting Upload Dashboard (recounting_dashboard_upload.py) ---
|
||||
# Bind port for the upload-first recount dashboard (distinct from 5002)
|
||||
RECOUNTING_UPLOAD_PORT=5003
|
||||
# Directory where uploaded MP4 files are stored
|
||||
UPLOAD_DIR=/opt/bytetrack-counter/uploads
|
||||
# Command to launch the recounting counter process (placeholder, fill in real command).
|
||||
# {path} is substituted with the absolute path of the uploaded MP4 file.
|
||||
RECOUNT_CMD=bytetrack-counter config.env --source {path}
|
||||
# Template file for the upload dashboard: "recounting_upload.html" (default)
|
||||
RECOUNTING_UPLOAD_TEMPLATE=recounting_upload.html
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recounting upload dashboard — upload MP4 then launch recount via commander.
|
||||
Consumes live counter + recount APIs, proxies recount live-video for preview.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, render_template, jsonify, request, Response, send_file
|
||||
from werkzeug.serving import WSGIRequestHandler
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
log = logging.getLogger('werkzeug')
|
||||
log.setLevel(logging.ERROR)
|
||||
|
||||
app = Flask(__name__, template_folder="templates")
|
||||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
|
||||
app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 * 1024 # 512 MB
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", str(REPO_ROOT / "uploads")))
|
||||
LIVE_API_URL = os.getenv("LIVE_API_URL", "http://localhost:5000")
|
||||
RECOUNT_API_URL = os.getenv("RECOUNT_API_URL", "http://localhost:5001")
|
||||
|
||||
SITE_NAME = os.getenv("SITE_NAME", "RECOUNT")
|
||||
TEMPLATE = os.getenv("RECOUNTING_UPLOAD_TEMPLATE", "recounting_upload.html")
|
||||
|
||||
DASHBOARD_PORT = int(os.getenv("RECOUNTING_UPLOAD_PORT", "5003"))
|
||||
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
||||
|
||||
RECOUNT_CMD = os.getenv("RECOUNT_CMD", "bytetrack-counter config.env --source {path}")
|
||||
|
||||
_http_session = requests.Session()
|
||||
_http_session.timeout = 3
|
||||
|
||||
_FILENAME_RE = re.compile(r"batch[_-]?(\d+)", re.IGNORECASE)
|
||||
|
||||
_recount_proc = None
|
||||
_recount_lock = threading.Lock()
|
||||
_current_file = None
|
||||
|
||||
|
||||
def _parse_filename(filename):
|
||||
m = _FILENAME_RE.search(filename)
|
||||
if m:
|
||||
return int(m.group(1)), None
|
||||
return None, None
|
||||
|
||||
|
||||
def _api_get(base_url, path, default=None):
|
||||
try:
|
||||
resp = _http_session.get(f"{base_url}{path}")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def _stop_recount_locked():
|
||||
global _recount_proc
|
||||
if _recount_proc:
|
||||
try:
|
||||
_recount_proc.kill()
|
||||
_recount_proc.wait(timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
_recount_proc = None
|
||||
|
||||
|
||||
def _folder_date(filepath, base):
|
||||
rel = Path(filepath).resolve().relative_to(base.resolve())
|
||||
parts = rel.parts
|
||||
if len(parts) >= 2:
|
||||
return parts[0]
|
||||
return None
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template(TEMPLATE, site_name=SITE_NAME, upload_dir=str(UPLOAD_DIR))
|
||||
|
||||
|
||||
@app.route("/api/live-progress")
|
||||
def api_live_progress():
|
||||
data = _api_get(LIVE_API_URL, "/api/current-batch")
|
||||
if data and data.get("success"):
|
||||
return jsonify(data)
|
||||
return jsonify({"success": False, "count": 0, "batch_number": None, "error": "Live unreachable"}), 200
|
||||
|
||||
|
||||
@app.route("/api/recount-progress")
|
||||
def api_recount_progress():
|
||||
data = _api_get(RECOUNT_API_URL, "/api/current-batch")
|
||||
if data and data.get("success"):
|
||||
return jsonify(data)
|
||||
return jsonify({"success": False, "count": 0, "batch_number": None, "error": "Recount unreachable"}), 200
|
||||
|
||||
|
||||
@app.route("/api/uploads")
|
||||
def api_uploads():
|
||||
files = []
|
||||
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||
for f in sorted(Path(UPLOAD_DIR).glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
st = f.stat()
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"path": str(f),
|
||||
"size": st.st_size,
|
||||
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
||||
})
|
||||
return jsonify(files)
|
||||
|
||||
|
||||
@app.route("/api/upload", methods=["POST"])
|
||||
def api_upload():
|
||||
if "file" not in request.files:
|
||||
return jsonify({"success": False, "error": "No file field"}), 400
|
||||
f = request.files["file"]
|
||||
if not f.filename:
|
||||
return jsonify({"success": False, "error": "No file selected"}), 400
|
||||
if not f.filename.lower().endswith(".mp4"):
|
||||
return jsonify({"success": False, "error": "Only .mp4 files allowed"}), 400
|
||||
|
||||
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||
safe = secure_filename(f.filename)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
dest_name = f"{ts}_{safe}"
|
||||
dest_path = Path(UPLOAD_DIR) / dest_name
|
||||
|
||||
f.save(str(dest_path))
|
||||
st = dest_path.stat()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"name": dest_name,
|
||||
"path": str(dest_path),
|
||||
"size": st.st_size,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/delete-upload", methods=["POST"])
|
||||
def api_delete_upload():
|
||||
data = request.get_json(force=True) or {}
|
||||
fpath = data.get("path", "")
|
||||
if not fpath or not os.path.isfile(fpath):
|
||||
return jsonify({"success": False, "error": "File not found"}), 404
|
||||
f = Path(fpath)
|
||||
if f.parent.resolve() != Path(UPLOAD_DIR).resolve():
|
||||
return jsonify({"success": False, "error": "Not in upload dir"}), 403
|
||||
f.unlink()
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@app.route("/api/start-recount", methods=["POST"])
|
||||
def start_recount():
|
||||
global _recount_proc, _current_file
|
||||
data = request.get_json(force=True) or {}
|
||||
upload_path = data.get("path", "")
|
||||
if not upload_path:
|
||||
return jsonify({"success": False, "error": "Missing 'path'"}), 400
|
||||
if not os.path.isfile(upload_path):
|
||||
return jsonify({"success": False, "error": f"File not found: {upload_path}"}), 404
|
||||
|
||||
with _recount_lock:
|
||||
_stop_recount_locked()
|
||||
_current_file = upload_path
|
||||
cmd_str = RECOUNT_CMD.replace("{path}", shlex.quote(str(upload_path)))
|
||||
try:
|
||||
_recount_proc = subprocess.Popen(cmd_str, shell=True)
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount started: {cmd_str}")
|
||||
except Exception as e:
|
||||
_current_file = None
|
||||
return jsonify({"success": False, "error": f"Failed to start: {e}"}), 500
|
||||
|
||||
try:
|
||||
resp = _http_session.post(f"{RECOUNT_API_URL}/api/reset", timeout=5)
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → {resp.status_code}")
|
||||
except requests.exceptions.Timeout:
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → timeout")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → unreachable: {e}")
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → error: {e}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"stream_url": "/api/proxy-stream",
|
||||
"file": Path(upload_path).name,
|
||||
"path": upload_path,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/stop-recount", methods=["POST"])
|
||||
def stop_recount():
|
||||
global _current_file
|
||||
with _recount_lock:
|
||||
_stop_recount_locked()
|
||||
_current_file = None
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@app.route("/api/state")
|
||||
def api_state():
|
||||
with _recount_lock:
|
||||
running = _recount_proc is not None and _recount_proc.poll() is None
|
||||
current = _current_file
|
||||
result = {
|
||||
"streaming": running,
|
||||
}
|
||||
if current:
|
||||
result["file"] = current
|
||||
result["file_name"] = Path(current).name
|
||||
result["stream_url"] = "/api/proxy-stream"
|
||||
batch_num, date_str = _parse_filename(Path(current).name)
|
||||
if batch_num is not None:
|
||||
result["batch_number"] = batch_num
|
||||
result["batch_date"] = date_str
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/api/proxy-stream")
|
||||
def api_proxy_stream():
|
||||
try:
|
||||
resp = _http_session.get(f"{RECOUNT_API_URL}/api/live-video", stream=True, timeout=5)
|
||||
if resp.status_code != 200:
|
||||
return Response("stream unavailable", status=502)
|
||||
|
||||
def generate():
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
yield chunk
|
||||
resp.close()
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype=resp.headers.get("Content-Type", "multipart/x-mixed-replace; boundary=frame"),
|
||||
)
|
||||
except Exception:
|
||||
return Response("stream unavailable", status=502)
|
||||
|
||||
|
||||
@app.route("/api/batch-result")
|
||||
def api_batch_result():
|
||||
fpath = request.args.get("file", "")
|
||||
filename = Path(fpath).name
|
||||
batch_num, _ = _parse_filename(filename)
|
||||
if batch_num is None:
|
||||
return jsonify({"success": False, "error": "Cannot parse batch number from filename"}), 200
|
||||
|
||||
def _match_in_batches(batches):
|
||||
for b in batches:
|
||||
if b.get("batch_number") == batch_num:
|
||||
return b
|
||||
return None
|
||||
|
||||
date_str = _folder_date(fpath, Path(UPLOAD_DIR))
|
||||
if date_str:
|
||||
data = _api_get(LIVE_API_URL, f"/api/day-detail/{date_str}")
|
||||
if data:
|
||||
b = _match_in_batches(data.get("batches", []))
|
||||
if b:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"batch_number": batch_num,
|
||||
"date": date_str,
|
||||
"count": b["count"],
|
||||
"start_time": b.get("start_time"),
|
||||
"end_time": b.get("end_time"),
|
||||
})
|
||||
|
||||
recent = _api_get(LIVE_API_URL, "/api/recent-batches?limit=100")
|
||||
if recent:
|
||||
for entry in recent if isinstance(recent, list) else recent.get("batches", []):
|
||||
if isinstance(entry, dict) and entry.get("batch_number") == batch_num:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"batch_number": batch_num,
|
||||
"date": entry.get("date", ""),
|
||||
"count": entry["count"],
|
||||
"start_time": entry.get("start_time"),
|
||||
"end_time": entry.get("end_time"),
|
||||
})
|
||||
|
||||
return jsonify({"success": False, "error": f"Batch #{batch_num} not found"}), 200
|
||||
|
||||
|
||||
@app.route("/api/download-mp4")
|
||||
def api_download_mp4():
|
||||
fpath = request.args.get("file", "")
|
||||
if not fpath or not os.path.isfile(fpath):
|
||||
return jsonify({"success": False, "error": "File not found"}), 404
|
||||
return send_file(fpath, as_attachment=True, download_name=Path(fpath).name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
WSGIRequestHandler.protocol_version = "HTTP/1.1"
|
||||
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||
print(f"Recounting upload dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
|
||||
print(f"Live API: {LIVE_API_URL}")
|
||||
print(f"Recount API: {RECOUNT_API_URL}")
|
||||
print(f"Upload dir: {UPLOAD_DIR}")
|
||||
print(f"Recount command: {RECOUNT_CMD}")
|
||||
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
|
||||
@@ -0,0 +1,977 @@
|
||||
<!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>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Orbitron:wght@400;500;700;900&display=swap');
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg-deep: #06080e;
|
||||
--bg-surface: rgba(12, 16, 28, 0.9);
|
||||
--border-glow: rgba(0, 240, 255, 0.12);
|
||||
--accent: #00f0ff;
|
||||
--accent2: #7b2fff;
|
||||
--accent3: #ff2d78;
|
||||
--accent4: #00ff88;
|
||||
--text-primary: #dce2f0;
|
||||
--text-secondary: #6b7394;
|
||||
--glass: rgba(12, 16, 32, 0.7);
|
||||
--cell-bg: rgba(255, 255, 255, 0.02);
|
||||
--cell-border: rgba(255, 255, 255, 0.05);
|
||||
--card-bg: rgba(255, 255, 255, 0.025);
|
||||
--divider: rgba(255, 255, 255, 0.05);
|
||||
--input-bg: rgba(0, 0, 0, 0.4);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
position: fixed; inset: 0; z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 240, 255, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 240, 255, 0.025) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
animation: gridScroll 20s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes gridScroll {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 64px 64px; }
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: fixed; border-radius: 50%; filter: blur(140px); pointer-events: none;
|
||||
animation: orbFloat 18s ease-in-out infinite;
|
||||
}
|
||||
.orb-1 { width: 600px; height: 600px; background: rgba(0, 240, 255, 0.04); top: -250px; left: -150px; }
|
||||
.orb-2 { width: 500px; height: 500px; background: rgba(123, 47, 255, 0.04); bottom: -200px; right: -100px; animation-delay: -6s; }
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(50px, -40px) scale(1.06); }
|
||||
66% { transform: translate(-30px, 30px) scale(0.94); }
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 1600px; margin: 0 auto; padding: 24px 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 18px 28px; margin-bottom: 24px;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 14px; }
|
||||
.logo-icon {
|
||||
width: 44px; height: 44px;
|
||||
background: linear-gradient(135deg, var(--accent3), var(--accent2));
|
||||
border-radius: 11px; font-size: 22px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 0 28px rgba(255, 45, 120, 0.35);
|
||||
animation: logoPulse 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes logoPulse {
|
||||
0%, 100% { box-shadow: 0 0 22px rgba(255, 45, 120, 0.3); }
|
||||
50% { box-shadow: 0 0 44px rgba(255, 45, 120, 0.6); }
|
||||
}
|
||||
.header h1 {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 20px; font-weight: 700; letter-spacing: 3px;
|
||||
background: linear-gradient(90deg, var(--accent3), var(--accent2));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.header-sub { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; }
|
||||
.header-right { display: flex; align-items: center; gap: 18px; }
|
||||
.live-chip {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 9px; letter-spacing: 1.5px; padding: 5px 12px; border-radius: 12px;
|
||||
font-weight: 600; text-transform: uppercase;
|
||||
background: rgba(255, 45, 120, 0.1); color: var(--accent3);
|
||||
border: 1px solid rgba(255, 45, 120, 0.2);
|
||||
}
|
||||
.live-chip .dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent3); box-shadow: 0 0 8px var(--accent3);
|
||||
animation: blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
.live-chip.live-chip-green {
|
||||
background: rgba(0, 255, 136, 0.1); color: var(--accent4);
|
||||
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
.live-chip.live-chip-green .dot {
|
||||
background: var(--accent4); box-shadow: 0 0 8px var(--accent4);
|
||||
}
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||
|
||||
.section-title {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.counters-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.panel {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
padding: 24px 26px;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.counter-card {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.counter-card .counter-val {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 56px; font-weight: 700;
|
||||
background: linear-gradient(180deg, #fff 0%, var(--accent) 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.counter-card.recount .counter-val {
|
||||
background: linear-gradient(180deg, #fff 0%, var(--accent3) 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.counter-card .counter-label { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; }
|
||||
.counter-card .counter-sub {
|
||||
font-size: 9px; color: var(--text-secondary); margin-top: 6px;
|
||||
}
|
||||
.counter-card .counter-sub .batch-num {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 18px; font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.counter-status {
|
||||
font-size: 9px; letter-spacing: 1px; margin-top: 10px;
|
||||
padding: 4px 12px; border-radius: 6px; display: inline-block;
|
||||
}
|
||||
.counter-status.connected {
|
||||
color: var(--accent4); background: rgba(0, 255, 136, 0.06);
|
||||
}
|
||||
.counter-status.disconnected {
|
||||
color: var(--accent3); background: rgba(255, 45, 120, 0.06);
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.video-panel {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
position: relative; overflow: hidden;
|
||||
min-height: 340px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.video-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 10px; padding: 0 12px;
|
||||
}
|
||||
.video-container {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
background: #000; border-radius: 10px;
|
||||
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(--text-secondary);
|
||||
padding: 40px;
|
||||
}
|
||||
.video-placeholder .vp-icon {
|
||||
font-size: 48px; opacity: 0.15; margin-bottom: 12px;
|
||||
}
|
||||
.video-placeholder .vp-text {
|
||||
font-size: 11px; letter-spacing: 1px;
|
||||
}
|
||||
.video-placeholder .vp-hint {
|
||||
font-size: 9px; margin-top: 8px; opacity: 0.5;
|
||||
}
|
||||
.video-status {
|
||||
font-size: 9px; letter-spacing: 1px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 6px;
|
||||
}
|
||||
.video-status.connected {
|
||||
color: var(--accent4); background: rgba(0, 255, 136, 0.06);
|
||||
}
|
||||
.video-status.stopped {
|
||||
color: var(--text-secondary); background: var(--cell-bg);
|
||||
}
|
||||
.video-toggle {
|
||||
background: none; border: 1px solid var(--cell-border);
|
||||
border-radius: 8px; padding: 6px 14px; cursor: pointer;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px; letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
transition: border-color 0.3s, color 0.3s;
|
||||
}
|
||||
.video-toggle:hover { border-color: var(--accent); color: var(--text-primary); }
|
||||
.video-toggle.active { border-color: var(--accent4); color: var(--accent4); }
|
||||
|
||||
.file-panel {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.file-panel .fp-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.upload-area {
|
||||
border: 2px dashed var(--cell-border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.upload-area:hover {
|
||||
border-color: var(--accent2);
|
||||
background: rgba(123, 47, 255, 0.04);
|
||||
}
|
||||
.upload-area.dragover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 240, 255, 0.04);
|
||||
}
|
||||
.upload-area .upload-icon {
|
||||
font-size: 28px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.upload-area .upload-text {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.upload-area .upload-hint {
|
||||
font-size: 8px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.upload-row {
|
||||
display: flex;
|
||||
align-items: center; gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.upload-row .file-name {
|
||||
flex: 1;
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
padding: 6px 10px;
|
||||
background: var(--cell-bg);
|
||||
border: 1px solid var(--cell-border);
|
||||
border-radius: 6px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.upload-row .file-name.has-file {
|
||||
color: var(--accent4);
|
||||
}
|
||||
.file-list {
|
||||
flex: 1; overflow-y: auto; max-height: 300px;
|
||||
}
|
||||
.file-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 12px; margin-bottom: 4px;
|
||||
background: var(--cell-bg);
|
||||
border: 1px solid var(--cell-border); border-radius: 8px;
|
||||
cursor: pointer; transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.file-item:hover { border-color: var(--border-glow); background: var(--card-bg); }
|
||||
.file-item.selected {
|
||||
border-color: var(--accent2);
|
||||
background: rgba(123, 47, 255, 0.08);
|
||||
}
|
||||
.file-item.streaming {
|
||||
border-color: var(--accent3);
|
||||
background: rgba(255, 45, 120, 0.08);
|
||||
}
|
||||
.file-item .fi-name {
|
||||
font-size: 10px; color: var(--text-primary);
|
||||
word-break: break-all; flex: 1;
|
||||
}
|
||||
.file-item .fi-size {
|
||||
font-size: 8px; color: var(--text-secondary);
|
||||
margin-left: 8px; white-space: nowrap;
|
||||
}
|
||||
.file-item .fi-icon {
|
||||
font-size: 14px; margin-right: 8px;
|
||||
color: var(--accent3);
|
||||
}
|
||||
.file-item.streaming .fi-icon { color: var(--accent4); animation: blink 1.5s ease-in-out infinite; }
|
||||
|
||||
.file-item .fi-delete {
|
||||
background: none; border: 1px solid var(--cell-border);
|
||||
border-radius: 5px; color: var(--text-secondary);
|
||||
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(--accent3); color: var(--accent3);
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
font-size: 9px; letter-spacing: 1px;
|
||||
margin-top: 8px; text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.upload-status.success { color: var(--accent4); }
|
||||
.upload-status.error { color: var(--accent3); }
|
||||
|
||||
.btn {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px;
|
||||
padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600;
|
||||
border: none; transition: opacity 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
color: #000;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(0, 240, 255, 0.3); }
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--accent3), var(--accent2));
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(255, 45, 120, 0.4); }
|
||||
.btn-ghost {
|
||||
background: var(--cell-bg); color: var(--text-secondary);
|
||||
border: 1px solid var(--cell-border);
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--accent); color: var(--text-primary); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
|
||||
.btn-row { display: flex; gap: 8px; margin-top: 12px; }
|
||||
|
||||
.footer {
|
||||
margin-top: 24px; text-align: center;
|
||||
font-size: 9px; letter-spacing: 2px; color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--text-secondary); border-radius: 3px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.counters-row, .bottom-row { grid-template-columns: 1fr; }
|
||||
.container { padding: 12px 8px; }
|
||||
.header { flex-direction: column; gap: 10px; padding: 14px 16px; }
|
||||
.counter-card .counter-val { font-size: 40px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-grid"></div>
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
|
||||
<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">
|
||||
<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:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></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 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-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(--text-secondary);text-align:center;padding:20px;">
|
||||
No uploads yet
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<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="footer">{{ site_name }} Recounting Upload Dashboard</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedFile = null;
|
||||
let streaming = false;
|
||||
let streamUrl = '';
|
||||
let pendingUploadFile = null;
|
||||
let batchResultLoaded = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadRecountProgress();
|
||||
loadUploads();
|
||||
syncState();
|
||||
|
||||
setInterval(loadRecountProgress, 500);
|
||||
setInterval(loadUploads, 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';
|
||||
}
|
||||
|
||||
function showUploadStatus(msg, cls) {
|
||||
var el = document.getElementById('upload-status');
|
||||
el.textContent = msg;
|
||||
el.className = 'upload-status ' + (cls || '');
|
||||
}
|
||||
|
||||
async 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...', '');
|
||||
try {
|
||||
var form = new FormData();
|
||||
form.append('file', pendingUploadFile);
|
||||
var res = await fetch('/api/upload', { method: 'POST', body: form });
|
||||
var data = await res.json();
|
||||
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;
|
||||
document.getElementById('btn-start').disabled = false;
|
||||
document.getElementById('btn-download').disabled = false;
|
||||
document.getElementById('btn-delete').disabled = false;
|
||||
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';
|
||||
}
|
||||
|
||||
function selectFile(path, name) {
|
||||
if (selectedFile === path) {
|
||||
selectedFile = null;
|
||||
batchResultLoaded = false;
|
||||
document.getElementById('btn-start').disabled = true;
|
||||
document.getElementById('btn-download').disabled = true;
|
||||
document.getElementById('btn-delete').disabled = true;
|
||||
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;
|
||||
document.getElementById('btn-start').disabled = false;
|
||||
document.getElementById('btn-download').disabled = false;
|
||||
document.getElementById('btn-delete').disabled = false;
|
||||
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(--text-secondary);text-align:center;padding:20px;">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 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';
|
||||
}
|
||||
|
||||
async function startRecount() {
|
||||
if (!selectedFile) 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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: selectedFile })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (!data.success) {
|
||||
alert('Failed: ' + (data.error || 'unknown'));
|
||||
btnStart.disabled = false;
|
||||
btnStart.textContent = '\u25b6 START RECOUNT';
|
||||
return;
|
||||
}
|
||||
streamUrl = data.stream_url;
|
||||
streaming = true;
|
||||
selectedFile = data.path;
|
||||
|
||||
document.getElementById('btn-stop').disabled = false;
|
||||
btnStart.textContent = 'STREAMING';
|
||||
btnStart.classList.add('btn-ghost');
|
||||
document.getElementById('video-status').innerHTML =
|
||||
'<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> streaming ' + data.file;
|
||||
document.getElementById('video-status').className = 'video-status connected';
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
btnStart.disabled = false;
|
||||
btnStart.textContent = '\u25b6 START RECOUNT';
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecount() {
|
||||
try {
|
||||
await fetch('/api/stop-recount', { method: 'POST' });
|
||||
} catch (err) {
|
||||
alert('Failed to stop recount');
|
||||
return;
|
||||
}
|
||||
streaming = false;
|
||||
streamUrl = '';
|
||||
selectedFile = null;
|
||||
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 = '';
|
||||
|
||||
var btnStart = document.getElementById('btn-start');
|
||||
var btnStop = document.getElementById('btn-stop');
|
||||
btnStart.disabled = true;
|
||||
btnStart.textContent = '\u25b6 START RECOUNT';
|
||||
btnStart.classList.remove('btn-ghost');
|
||||
btnStop.disabled = true;
|
||||
|
||||
document.getElementById('btn-download').disabled = true;
|
||||
document.getElementById('btn-delete').disabled = true;
|
||||
|
||||
document.getElementById('video-status').innerHTML =
|
||||
'<span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></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 {
|
||||
img.src = streamUrl + '?' + Date.now();
|
||||
img.style.display = 'block';
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
btn.textContent = 'STREAM ON';
|
||||
btn.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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.file) {
|
||||
selectedFile = data.file;
|
||||
document.getElementById('btn-start').disabled = data.streaming;
|
||||
document.getElementById('btn-download').disabled = false;
|
||||
document.getElementById('btn-delete').disabled = false;
|
||||
} else {
|
||||
selectedFile = null;
|
||||
batchResultLoaded = false;
|
||||
document.getElementById('btn-start').disabled = true;
|
||||
document.getElementById('btn-download').disabled = true;
|
||||
document.getElementById('btn-delete').disabled = true;
|
||||
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';
|
||||
}
|
||||
if (data.streaming && data.file) {
|
||||
streaming = true;
|
||||
streamUrl = data.stream_url;
|
||||
document.getElementById('btn-stop').disabled = false;
|
||||
document.getElementById('btn-start').disabled = true;
|
||||
document.getElementById('btn-start').textContent = 'STREAMING';
|
||||
document.getElementById('btn-start').classList.add('btn-ghost');
|
||||
document.getElementById('video-status').innerHTML =
|
||||
'<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> streaming ' + (data.file_name || '');
|
||||
document.getElementById('video-status').className = 'video-status connected';
|
||||
loadBatchResult(data.file);
|
||||
} else if (!data.streaming && data.file) {
|
||||
loadBatchResult(data.file);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
document.getElementById('btn-start').disabled = true;
|
||||
document.getElementById('btn-download').disabled = true;
|
||||
document.getElementById('btn-delete').disabled = true;
|
||||
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>
|
||||
Reference in new issue
Block a user