Compare commits
14
Commits
f01601d1d7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fccc285ab3 | ||
|
|
634d649069 | ||
|
|
d9493ece5a | ||
|
|
2606de9e78 | ||
|
|
3624993189 | ||
|
|
5f39d47df8 | ||
|
|
6be017c4f0 | ||
|
|
6043197d72 | ||
|
|
d909bc85bd | ||
|
|
763bcab3a6 | ||
|
|
3ac73f6fd9 | ||
|
|
89673db0bf | ||
|
|
99e126de1b | ||
|
|
5df224116d |
No files matched your search
@@ -9,7 +9,7 @@
|
|||||||
- `batch_store.py` — shared SQLite persistence + batch state machine.
|
- `batch_store.py` — shared SQLite persistence + batch state machine.
|
||||||
- `counter_dashboard.py` — Flask dashboard on port 5000, same DB.
|
- `counter_dashboard.py` — Flask dashboard on port 5000, same DB.
|
||||||
- `recounting_dashboard.py` — Flask recounting dashboard on port 5002, consumes live + recount APIs.
|
- `recounting_dashboard.py` — Flask recounting dashboard on port 5002, consumes live + recount APIs.
|
||||||
- `recounting_dashboard_upload.py` — Flask upload+recount dashboard on port 5003; uploads MP4 first, then launches `RECOUNT_CMD --source <mp4>`; START RECOUNT stays disabled until an upload completes.
|
- `recounting_dashboard_upload.py` — Flask upload+recount dashboard on port 5003; uploads MP4 first, then **LOAD MP4** spawns `RECOUNT_CMD` (process loads and pauses), then **START RECOUNT** touches `{SHM_DIR}/.continue` to begin counting.
|
||||||
|
|
||||||
## No build / test / lint
|
## No build / test / lint
|
||||||
|
|
||||||
@@ -43,11 +43,15 @@ PYTHONNOUSERSITE=1 venv/bin/python recounting_dashboard_upload.py
|
|||||||
- **`numpy<2`** is required for `rknn-toolkit-lite2` compatibility.
|
- **`numpy<2`** is required for `rknn-toolkit-lite2` compatibility.
|
||||||
- **Install path in service files is `/opt/bytetrack-counter`** (not the `/opt/jetson-counter` mentioned in README/DEPLOY). The `.env.example` also reflects `/opt/bytetrack-counter`.
|
- **Install path in service files is `/opt/bytetrack-counter`** (not the `/opt/jetson-counter` mentioned in README/DEPLOY). The `.env.example` also reflects `/opt/bytetrack-counter`.
|
||||||
- Service user is **`root`**, not `jetson` (despite README saying otherwise).
|
- Service user is **`root`**, not `jetson` (despite README saying otherwise).
|
||||||
- Two systemd units: `bytetrack-counter.service` + `bytetrack-counter-dashboard.service`.
|
- Three systemd units: `bytetrack-counter.service`, `bytetrack-counter-dashboard.service`, and `bytetrack-recounting-upload-dashboard.service` (upload recounting dashboard, port 5003).
|
||||||
- `counter_live.py` (TensorRT) is Jetson-only and won't work on RK3588.
|
- `counter_live.py` (TensorRT) is Jetson-only and won't work on RK3588.
|
||||||
- **Reset flow**: `POST /api/reset` → deletes state JSON + touches `{SHM_DIR}/.reset`. `batch_store._check_reset_signal()` watches this marker and clears in-memory state on next crossing.
|
- **Reset flow**: `POST /api/reset` → deletes state JSON + touches `{SHM_DIR}/.reset`. `batch_store._check_reset_signal()` watches this marker and clears in-memory state on next crossing.
|
||||||
|
- **Recount continue flow** (upload dashboard, port 5003): `POST /api/load-mp4 {path}` kills the old process, spawns `RECOUNT_CMD` (loads + pauses), and POSTs `/api/reset` to the recount node. `POST /api/start-recount` (no body, requires a loaded file) touches `{SHM_DIR}/.continue`; the C++ process consumes that marker to begin counting. The dashboard never clears the marker.
|
||||||
|
- **OUTPUT_DIR browser + copy-on-load** (upload dashboard, port 5003): `GET /api/mp4-files` lists MP4s from `OUTPUT_DIR` folder-grouped (mirrors `recounting_dashboard.py`'s `/api/mp4-files` shape). Selecting one and pressing LOAD MP4 makes `POST /api/load-mp4` copy it into `UPLOAD_DIR` first (chunked, progress reported via `GET /api/copy-progress` → `{active,name,total,done,pct}`, frontend polls every 250 ms to drive the progress bar). Name collision in `UPLOAD_DIR` → 400 error (no overwrite). The OUTPUT_DIR browser is read-only (no delete).
|
||||||
|
- **Recount STOP sweep**: `POST /api/stop-recount` kills the tracked process group (`killpg` TERM→5s→KILL) then sweeps the host via `/proc` for any process whose argv matches the `RECOUNT_CMD` binary name (basename of the first token, so it also catches orphans from a dashboard restart); TERM→`_SWEEP_GRACE_SEC`→KILL with `_SWEEP_MAX_RETRIES` retries, all under `_recount_lock`. Returns `{success, killed, remaining}`; `remaining` is 0 in the normal path, and survivors (e.g. D-state) are logged rather than force-hanging.
|
||||||
- **Upload filename contract**: uploads are expected as `batch_XX_YYYYMMDD_HHmmSS.mp4` (XX = batch number, timestamp = date). `recounting_dashboard_upload.py` parses both to query the live API's `/api/day-detail/<YYYY-MM-DD>` for the recorded count.
|
- **Upload filename contract**: uploads are expected as `batch_XX_YYYYMMDD_HHmmSS.mp4` (XX = batch number, timestamp = date). `recounting_dashboard_upload.py` parses both to query the live API's `/api/day-detail/<YYYY-MM-DD>` for the recorded count.
|
||||||
- **`RECOUNT_CMD`** in the upload dashboard is a template with a `{path}` placeholder, e.g. `bytetrack-counter config.env --source {path}`; the uploaded file path is `shlex.quote()`d before substitution.
|
- **`RECOUNT_CMD`** in the upload dashboard is a template with a `{path}` placeholder, e.g. `bytetrack-counter-cpp config.env --source {path}`; the uploaded file path is `shlex.quote()`d before substitution, and the command is spawned by **LOAD MP4** (loads + pauses until `.continue` is touched).
|
||||||
|
- **Cross-dashboard header links**: `UPLOAD_DASHBOARD_URL` (live → upload dashboard, `counter_dashboard.py`) and `LIVE_DASHBOARD_URL` (upload → live dashboard, `recounting_dashboard_upload.py`); empty value hides the header link.
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ env: MIN_OBJECT_PER_BATCH → min count to persist batch (default 60)
|
|||||||
env: MIN_DURATION_PER_BATCH → min seconds to persist batch (default 60)
|
env: MIN_DURATION_PER_BATCH → min seconds to persist batch (default 60)
|
||||||
env: LIVE_STREAM_ENABLED → write annotated JPEG snapshot each N frames
|
env: LIVE_STREAM_ENABLED → write annotated JPEG snapshot each N frames
|
||||||
env: EXPORT_CSV → write per-crossing CSV (default true)
|
env: EXPORT_CSV → write per-crossing CSV (default true)
|
||||||
|
env: UPLOAD_DASHBOARD_URL → header link to upload recounting dashboard (default empty → hidden)
|
||||||
|
|
||||||
### Recounting dashboard config
|
### Recounting dashboard config
|
||||||
```
|
```
|
||||||
@@ -68,8 +69,10 @@ env: GO2RTC_STREAM_NAME → go2rtc stream name for recount preview (default "rec
|
|||||||
```
|
```
|
||||||
env: RECOUNTING_UPLOAD_PORT → port for upload UI (default 5003)
|
env: RECOUNTING_UPLOAD_PORT → port for upload UI (default 5003)
|
||||||
env: UPLOAD_DIR → dir for uploaded MP4s (default <repo>/uploads)
|
env: UPLOAD_DIR → dir for uploaded MP4s (default <repo>/uploads)
|
||||||
|
env: OUTPUT_DIR → dir scanned for "From Output Directory" browser (default /opt/bytetrack-counter)
|
||||||
env: RECOUNT_CMD → replay command template with {path} placeholder (default "bytetrack-counter config.env --source {path}")
|
env: RECOUNT_CMD → replay command template with {path} placeholder (default "bytetrack-counter config.env --source {path}")
|
||||||
env: RECOUNTING_UPLOAD_TEMPLATE → template (default recounting_upload.html)
|
env: RECOUNTING_UPLOAD_TEMPLATE → template (default recounting_upload_lamborghini.html)
|
||||||
|
env: LIVE_DASHBOARD_URL → header link to live counter dashboard (default empty → hidden)
|
||||||
```
|
```
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -121,9 +124,12 @@ api: POST /api/stop-recount → kill ffmpeg + clear current file
|
|||||||
api: GET / → upload recounting HTML (template via RECOUNTING_UPLOAD_TEMPLATE env var)
|
api: GET / → upload recounting HTML (template via RECOUNTING_UPLOAD_TEMPLATE env var)
|
||||||
api: POST /api/upload → multipart .mp4 (≤512MB), saved as original filename in UPLOAD_DIR; returns {name, path, size}
|
api: POST /api/upload → multipart .mp4 (≤512MB), saved as original filename in UPLOAD_DIR; returns {name, path, size}
|
||||||
api: GET /api/uploads → list uploaded MP4s sorted by mtime desc → [ {name, path, size, mtime} ]
|
api: GET /api/uploads → list uploaded MP4s sorted by mtime desc → [ {name, path, size, mtime} ]
|
||||||
|
api: GET /api/mp4-files → list OUTPUT_DIR MP4s grouped by folder (date), files sorted by batch # → [ {folder, files:[{name, path, size, mtime}]} ]
|
||||||
|
api: GET /api/copy-progress → {active:false} idle | {active, name, total, done, pct} during an in-progress copy
|
||||||
api: POST /api/delete-upload {path} → delete uploaded MP4 (path restricted to UPLOAD_DIR)
|
api: POST /api/delete-upload {path} → delete uploaded MP4 (path restricted to UPLOAD_DIR)
|
||||||
api: POST /api/start-recount {path} → reset recount API then launch RECOUNT_CMD (path shlex-quoted); START RECOUNT disabled until upload completes
|
api: POST /api/load-mp4 {path} → if path outside UPLOAD_DIR: collision check (same-name in UPLOAD_DIR → 400), else copy (chunked, progress-reporting) into UPLOAD_DIR first; then kill old process, spawn RECOUNT_CMD (copied path shlex-quoted) paused, reset recount API; returns copied path
|
||||||
api: POST /api/stop-recount → kill recount process + clear current file
|
api: POST /api/start-recount → require loaded file, touch {SHM_DIR}/.continue to signal C++ process to begin counting
|
||||||
|
api: POST /api/stop-recount → killpg tracked group + host-wide /proc sweep of RECOUNT_CMD binary (TERM→grace→KILL, verify loop); returns {success, killed, remaining}
|
||||||
api: GET /api/state → {streaming, file, file_name, stream_url, batch_number, batch_date}
|
api: GET /api/state → {streaming, file, file_name, stream_url, batch_number, batch_date}
|
||||||
api: GET /api/proxy-stream → proxy MJPEG from RECOUNT_API_URL:/api/live-video (same-origin)
|
api: GET /api/proxy-stream → proxy MJPEG from RECOUNT_API_URL:/api/live-video (same-origin)
|
||||||
api: GET /api/live-progress → proxy to LIVE_API_URL:/api/current-batch
|
api: GET /api/live-progress → proxy to LIVE_API_URL:/api/current-batch
|
||||||
@@ -157,6 +163,11 @@ V18: .env missing → scripts fail at import (os.getenv falls back to defaults,
|
|||||||
V19: stream reconnect → reset motion detection state (prev_gray = None)
|
V19: stream reconnect → reset motion detection state (prev_gray = None)
|
||||||
V20: reset → delete STATE_FILE + touch {SHM_DIR}/.reset; batch_store._check_reset_signal() watches marker & clears in-memory state before next crossing
|
V20: reset → delete STATE_FILE + touch {SHM_DIR}/.reset; batch_store._check_reset_signal() watches marker & clears in-memory state before next crossing
|
||||||
V21: upload filename batch_XX_YYYYMMDD_HHmmSS.mp4 → batch# + date parsed; batch-result queries /api/day-detail/<YYYY-MM-DD> on live API
|
V21: upload filename batch_XX_YYYYMMDD_HHmmSS.mp4 → batch# + date parsed; batch-result queries /api/day-detail/<YYYY-MM-DD> on live API
|
||||||
|
V22: preview auto-on once per streaming session — enablePreview() on START RECOUNT + applyState streaming branch guarded by previewAutoOn flag; manual toggle persists until next session (flag reset only on stop); img.onerror retries cache-busted src while streaming
|
||||||
|
V23: .continue marker flow — LOAD MP4 spawns RECOUNT_CMD paused; START RECOUNT touches {SHM_DIR}/.continue; C++ process consumes marker to begin counting; dashboard never clears it (LOAD alone does not count)
|
||||||
|
V24: STOP → killpg tracked group then host-wide /proc sweep of RECOUNT_CMD binary (basename of first token); sweep runs under _recount_lock, TERM→_SWEEP_GRACE_SEC→KILL, bounded retries; returns {success, killed, remaining} with remaining==0 in normal path; survivors (D-state) logged, never force-hang
|
||||||
|
V25: LOAD MP4 on an OUTPUT_DIR file → copy into UPLOAD_DIR (original untouched); copy runs server-side, chunked, reports {done,total} to _copy_progress under _copy_lock; name collision in UPLOAD_DIR → 400 error, no overwrite
|
||||||
|
V26: OUTPUT_DIR browser → read-only, folder-grouped via /api/mp4-files (mirrors 5002 shape); no delete button on OUTPUT_DIR rows
|
||||||
```
|
```
|
||||||
|
|
||||||
## §T — Tasks
|
## §T — Tasks
|
||||||
@@ -178,6 +189,11 @@ T12|x|add recounting dashboard — dual-API counter panels, MP4 browser, go2rtc
|
|||||||
T13|x|add template system — DASHBOARD_TEMPLATE env var selects dashboard.html|lamborghini|tesla|
|
T13|x|add template system — DASHBOARD_TEMPLATE env var selects dashboard.html|lamborghini|tesla|
|
||||||
T14|x|harden reset API — separate SHM marker creation from state file cleanup; return 500 on SHM failure|V20
|
T14|x|harden reset API — separate SHM marker creation from state file cleanup; return 500 on SHM failure|V20
|
||||||
T15|x|add upload recounting dashboard — MP4 upload, progress bar, RECOUNT_CMD replay|V21
|
T15|x|add upload recounting dashboard — MP4 upload, progress bar, RECOUNT_CMD replay|V21
|
||||||
|
T16|x|add cross-dashboard header links — UPLOAD_DASHBOARD_URL (live→upload) & LIVE_DASHBOARD_URL (upload→live)|I.config
|
||||||
|
T17|x|auto-on live preview when recount running — enablePreview() on START + applyState; onerror retry while streaming|V22
|
||||||
|
T18|x|split recount flow — /api/load-mp4 spawns paused RECOUNT_CMD, /api/start-recount touches {SHM_DIR}/.continue; C++ consumes marker|V23
|
||||||
|
T19|x|hard-kill STOP — sweep all RECOUNT_CMD-binary processes via /proc, verify remaining==0, report killed/remaining|V24
|
||||||
|
T20|x|add OUTPUT_DIR browser to upload dashboard — /api/mp4-files listing, copy-on-load with progress polling into UPLOAD_DIR, collision guard|V25,V26
|
||||||
```
|
```
|
||||||
|
|
||||||
## §B — Bugs
|
## §B — Bugs
|
||||||
@@ -185,5 +201,6 @@ T15|x|add upload recounting dashboard — MP4 upload, progress bar, RECOUNT_CMD
|
|||||||
```
|
```
|
||||||
id|date|cause|fix
|
id|date|cause|fix
|
||||||
B1|2026-07-29|prev_gray not reset on stream reconnect → cv2.absdiff crash or false motion|V19
|
B1|2026-07-29|prev_gray not reset on stream reconnect → cv2.absdiff crash or false motion|V19
|
||||||
|
B2|2026-08-10|applyState re-ran enablePreview() every 1s poll, overriding manual STREAM toggle (OFF flipped back to ON)|V22
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Edge Counter Recounting Upload Dashboard (Flask, port 5003)
|
||||||
|
Documentation=file:///opt/bytetrack-counter/DEPLOY.md
|
||||||
|
After=network-online.target bytetrack-counter-dashboard.service
|
||||||
|
Wants=network-online.target bytetrack-counter-dashboard.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
|
||||||
|
WorkingDirectory=/opt/bytetrack-counter
|
||||||
|
|
||||||
|
EnvironmentFile=/opt/bytetrack-counter/.env
|
||||||
|
Environment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
Environment=PYTHONNOUSERSITE=1
|
||||||
|
Environment=FLASK_DEBUG=false
|
||||||
|
|
||||||
|
ExecStart=/opt/bytetrack-counter/venv/bin/python recounting_dashboard_upload.py
|
||||||
|
|
||||||
|
TimeoutStopSec=15
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StartLimitInterval=60s
|
||||||
|
StartLimitBurst=3
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=false
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+12
-5
@@ -159,6 +159,8 @@ SECRET_KEY=change-me-in-production
|
|||||||
DASHBOARD_HOST=0.0.0.0
|
DASHBOARD_HOST=0.0.0.0
|
||||||
# Listen port for the dashboard web UI
|
# Listen port for the dashboard web UI
|
||||||
DASHBOARD_PORT=5000
|
DASHBOARD_PORT=5000
|
||||||
|
# URL of the upload recounting dashboard (recounting_dashboard_upload.py); empty hides the header link
|
||||||
|
UPLOAD_DASHBOARD_URL=http://localhost:5003
|
||||||
# Enable Flask debug mode (true/false) — auto-reloads on code changes; disable in production
|
# Enable Flask debug mode (true/false) — auto-reloads on code changes; disable in production
|
||||||
FLASK_DEBUG=false
|
FLASK_DEBUG=false
|
||||||
# Fallback name for the active-batch JSON state file used by the dashboard
|
# Fallback name for the active-batch JSON state file used by the dashboard
|
||||||
@@ -183,8 +185,13 @@ RECOUNT_STREAM_NAME=stream_1
|
|||||||
RECOUNTING_UPLOAD_PORT=5003
|
RECOUNTING_UPLOAD_PORT=5003
|
||||||
# Directory where uploaded MP4 files are stored
|
# Directory where uploaded MP4 files are stored
|
||||||
UPLOAD_DIR=/opt/bytetrack-counter/uploads
|
UPLOAD_DIR=/opt/bytetrack-counter/uploads
|
||||||
# Command to launch the recounting counter process (placeholder, fill in real command).
|
# Directory scanned for the "From Output Directory" file browser (folder-grouped MP4s).
|
||||||
# {path} is substituted with the absolute path of the uploaded MP4 file.
|
# Selecting one of those files and pressing LOAD MP4 copies it into UPLOAD_DIR first.
|
||||||
RECOUNT_CMD=bytetrack-counter config.env --source {path}
|
OUTPUT_DIR=/opt/bytetrack-counter
|
||||||
# Template file for the upload dashboard: "recounting_upload.html" (default)
|
# Command launched by LOAD MP4 (bytetrack-counter-cpp); {path} is the uploaded MP4.
|
||||||
RECOUNTING_UPLOAD_TEMPLATE=recounting_upload.html
|
# The process loads + pauses until START RECOUNT touches {SHM_DIR}/.continue.
|
||||||
|
RECOUNT_CMD=bytetrack-counter-cpp config.env --source {path}
|
||||||
|
# Template file for the upload dashboard: "recounting_upload_lamborghini.html" (default) or "recounting_upload.html"
|
||||||
|
RECOUNTING_UPLOAD_TEMPLATE=recounting_upload_lamborghini.html
|
||||||
|
# URL of the live counter dashboard (counter_dashboard.py); empty hides the header link
|
||||||
|
LIVE_DASHBOARD_URL=http://localhost:5000
|
||||||
@@ -40,6 +40,7 @@ SHM_DIR = os.getenv("SHM_DIR", "/dev/shm/bytetrack-counter")
|
|||||||
|
|
||||||
SITE_NAME = os.getenv("SITE_NAME", "LIVE")
|
SITE_NAME = os.getenv("SITE_NAME", "LIVE")
|
||||||
TEMPLATE = os.getenv("DASHBOARD_TEMPLATE", "dashboard.html")
|
TEMPLATE = os.getenv("DASHBOARD_TEMPLATE", "dashboard.html")
|
||||||
|
UPLOAD_DASHBOARD_URL = os.getenv("UPLOAD_DASHBOARD_URL", "")
|
||||||
|
|
||||||
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
||||||
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||||
@@ -132,7 +133,7 @@ def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
|
|||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template(TEMPLATE, site_name=SITE_NAME)
|
return render_template(TEMPLATE, site_name=SITE_NAME, upload_dashboard_url=UPLOAD_DASHBOARD_URL)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/current-batch")
|
@app.route("/api/current-batch")
|
||||||
|
|||||||
+250
-28
@@ -8,6 +8,8 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -31,11 +33,13 @@ app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 * 1024 # 512 MB
|
|||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent
|
REPO_ROOT = Path(__file__).resolve().parent
|
||||||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", str(REPO_ROOT / "uploads")))
|
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", str(REPO_ROOT / "uploads")))
|
||||||
|
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/opt/bytetrack-counter"))
|
||||||
LIVE_API_URL = os.getenv("LIVE_API_URL", "http://localhost:5000")
|
LIVE_API_URL = os.getenv("LIVE_API_URL", "http://localhost:5000")
|
||||||
RECOUNT_API_URL = os.getenv("RECOUNT_API_URL", "http://localhost:5001")
|
RECOUNT_API_URL = os.getenv("RECOUNT_API_URL", "http://localhost:5001")
|
||||||
|
|
||||||
SITE_NAME = os.getenv("SITE_NAME", "RECOUNT")
|
SITE_NAME = os.getenv("SITE_NAME", "RECOUNT")
|
||||||
TEMPLATE = os.getenv("RECOUNTING_UPLOAD_TEMPLATE", "recounting_upload.html")
|
TEMPLATE = os.getenv("RECOUNTING_UPLOAD_TEMPLATE", "recounting_upload_lamborghini.html")
|
||||||
|
LIVE_DASHBOARD_URL = os.getenv("LIVE_DASHBOARD_URL", "")
|
||||||
|
|
||||||
DASHBOARD_PORT = int(os.getenv("RECOUNTING_UPLOAD_PORT", "5003"))
|
DASHBOARD_PORT = int(os.getenv("RECOUNTING_UPLOAD_PORT", "5003"))
|
||||||
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||||
@@ -43,6 +47,12 @@ FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
|||||||
|
|
||||||
RECOUNT_CMD = os.getenv("RECOUNT_CMD", "bytetrack-counter config.env --source {path}")
|
RECOUNT_CMD = os.getenv("RECOUNT_CMD", "bytetrack-counter config.env --source {path}")
|
||||||
|
|
||||||
|
SHM_DIR = os.getenv("SHM_DIR", "/dev/shm/bytetrack-counter")
|
||||||
|
_CONTINUE_FLAG = Path(f"{SHM_DIR}/.continue")
|
||||||
|
|
||||||
|
_SWEEP_GRACE_SEC = 5
|
||||||
|
_SWEEP_MAX_RETRIES = 3
|
||||||
|
|
||||||
_http_session = requests.Session()
|
_http_session = requests.Session()
|
||||||
_http_session.timeout = 3
|
_http_session.timeout = 3
|
||||||
|
|
||||||
@@ -52,6 +62,9 @@ _recount_proc = None
|
|||||||
_recount_lock = threading.Lock()
|
_recount_lock = threading.Lock()
|
||||||
_current_file = None
|
_current_file = None
|
||||||
|
|
||||||
|
_copy_progress = {}
|
||||||
|
_copy_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _parse_filename(filename):
|
def _parse_filename(filename):
|
||||||
m = _FILENAME_RE.search(filename)
|
m = _FILENAME_RE.search(filename)
|
||||||
@@ -73,13 +86,85 @@ def _api_get(base_url, path, default=None):
|
|||||||
|
|
||||||
def _stop_recount_locked():
|
def _stop_recount_locked():
|
||||||
global _recount_proc
|
global _recount_proc
|
||||||
if _recount_proc:
|
proc = _recount_proc
|
||||||
|
_recount_proc = None
|
||||||
|
if proc is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(proc.pid)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.killpg(pgid, signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
try:
|
try:
|
||||||
_recount_proc.kill()
|
os.killpg(pgid, signal.SIGKILL)
|
||||||
_recount_proc.wait(timeout=3)
|
except (ProcessLookupError, PermissionError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=3)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
_recount_proc = None
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _recount_binary_name():
|
||||||
|
if not RECOUNT_CMD:
|
||||||
|
return "bytetrack-counter-cpp"
|
||||||
|
try:
|
||||||
|
first = shlex.split(RECOUNT_CMD)[0]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return "bytetrack-counter-cpp"
|
||||||
|
return Path(first).name or "bytetrack-counter-cpp"
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_matching_pids(binary):
|
||||||
|
for entry in os.listdir("/proc"):
|
||||||
|
if not entry.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{entry}/cmdline", "rb") as f:
|
||||||
|
raw = f.read().split(b"\x00")
|
||||||
|
except (FileNotFoundError, PermissionError, ProcessLookupError):
|
||||||
|
continue
|
||||||
|
args = [t.decode(errors="ignore") for t in raw if t]
|
||||||
|
for token in args:
|
||||||
|
for piece in token.split():
|
||||||
|
if Path(piece).name == binary:
|
||||||
|
yield int(entry)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _sweep_recount_processes(binary):
|
||||||
|
killed = 0
|
||||||
|
for _ in range(_SWEEP_MAX_RETRIES):
|
||||||
|
pids = list(_iter_matching_pids(binary))
|
||||||
|
if not pids:
|
||||||
|
break
|
||||||
|
for pid in pids:
|
||||||
|
if pid not in list(_iter_matching_pids(binary)):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
killed += 1
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
pass
|
||||||
|
time.sleep(_SWEEP_GRACE_SEC)
|
||||||
|
for pid in list(_iter_matching_pids(binary)):
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
return killed, len(list(_iter_matching_pids(binary)))
|
||||||
|
|
||||||
|
|
||||||
def _folder_date(filepath, base):
|
def _folder_date(filepath, base):
|
||||||
@@ -92,7 +177,7 @@ def _folder_date(filepath, base):
|
|||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template(TEMPLATE, site_name=SITE_NAME, upload_dir=str(UPLOAD_DIR))
|
return render_template(TEMPLATE, site_name=SITE_NAME, upload_dir=str(UPLOAD_DIR), output_dir=str(OUTPUT_DIR), live_dashboard_url=LIVE_DASHBOARD_URL)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/live-progress")
|
@app.route("/api/live-progress")
|
||||||
@@ -126,6 +211,55 @@ def api_uploads():
|
|||||||
return jsonify(files)
|
return jsonify(files)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/mp4-files")
|
||||||
|
def api_mp4_files():
|
||||||
|
folders = {}
|
||||||
|
output = Path(OUTPUT_DIR)
|
||||||
|
if not output.exists():
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
for d in sorted(output.iterdir(), reverse=True):
|
||||||
|
if not d.is_dir():
|
||||||
|
continue
|
||||||
|
folder_name = d.name
|
||||||
|
mp4s = sorted(d.glob("*.mp4"), key=lambda p: _parse_filename(p.name)[0] or 0)
|
||||||
|
if not mp4s:
|
||||||
|
continue
|
||||||
|
files = []
|
||||||
|
for f in mp4s:
|
||||||
|
st = f.stat()
|
||||||
|
files.append({
|
||||||
|
"name": f.name,
|
||||||
|
"path": str(f),
|
||||||
|
"size": st.st_size,
|
||||||
|
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
||||||
|
})
|
||||||
|
folders[folder_name] = files
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for folder, files in sorted(folders.items(), reverse=True):
|
||||||
|
result.append({"folder": folder, "files": files})
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/copy-progress")
|
||||||
|
def api_copy_progress():
|
||||||
|
with _copy_lock:
|
||||||
|
p = dict(_copy_progress)
|
||||||
|
if not p or not p.get("active"):
|
||||||
|
return jsonify({"active": False})
|
||||||
|
total = p.get("total") or 0
|
||||||
|
done = p.get("done") or 0
|
||||||
|
pct = round(done * 100.0 / total) if total else 0
|
||||||
|
return jsonify({
|
||||||
|
"active": True,
|
||||||
|
"name": p.get("name"),
|
||||||
|
"total": total,
|
||||||
|
"done": done,
|
||||||
|
"pct": pct,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/upload", methods=["POST"])
|
@app.route("/api/upload", methods=["POST"])
|
||||||
def api_upload():
|
def api_upload():
|
||||||
if "file" not in request.files:
|
if "file" not in request.files:
|
||||||
@@ -163,27 +297,7 @@ def api_delete_upload():
|
|||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/start-recount", methods=["POST"])
|
def _reset_recount_api():
|
||||||
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:
|
try:
|
||||||
resp = _http_session.post(f"{RECOUNT_API_URL}/api/reset", timeout=5)
|
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}")
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → {resp.status_code}")
|
||||||
@@ -194,6 +308,85 @@ def start_recount():
|
|||||||
except Exception as 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}")
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount reset: {RECOUNT_API_URL}/api/reset → error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn_recount_locked(upload_path):
|
||||||
|
global _recount_proc
|
||||||
|
cmd_str = RECOUNT_CMD.replace("{path}", shlex.quote(str(upload_path)))
|
||||||
|
_recount_proc = subprocess.Popen(cmd_str, shell=True, start_new_session=True)
|
||||||
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount loaded: {cmd_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_file_with_progress(src, dest):
|
||||||
|
global _copy_progress
|
||||||
|
total = os.path.getsize(src)
|
||||||
|
done = 0
|
||||||
|
with _copy_lock:
|
||||||
|
_copy_progress = {
|
||||||
|
"active": True,
|
||||||
|
"name": Path(src).name,
|
||||||
|
"total": total,
|
||||||
|
"done": 0,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(src, "rb") as fsrc, open(dest, "wb") as fdst:
|
||||||
|
while True:
|
||||||
|
buf = fsrc.read(1024 * 1024)
|
||||||
|
if not buf:
|
||||||
|
break
|
||||||
|
fdst.write(buf)
|
||||||
|
done += len(buf)
|
||||||
|
with _copy_lock:
|
||||||
|
_copy_progress["done"] = done
|
||||||
|
shutil.copystat(src, dest)
|
||||||
|
except Exception as e:
|
||||||
|
with _copy_lock:
|
||||||
|
_copy_progress["error"] = str(e)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
with _copy_lock:
|
||||||
|
_copy_progress["active"] = False
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/load-mp4", methods=["POST"])
|
||||||
|
def load_mp4():
|
||||||
|
global _current_file
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
src_path = data.get("path", "")
|
||||||
|
if not src_path:
|
||||||
|
return jsonify({"success": False, "error": "Missing 'path'"}), 400
|
||||||
|
if not os.path.isfile(src_path):
|
||||||
|
return jsonify({"success": False, "error": f"File not found: {src_path}"}), 404
|
||||||
|
|
||||||
|
src_path = str(Path(src_path).resolve())
|
||||||
|
upload_path = src_path
|
||||||
|
if Path(src_path).parent != Path(UPLOAD_DIR).resolve():
|
||||||
|
dest_path = str(Path(UPLOAD_DIR) / Path(src_path).name)
|
||||||
|
if os.path.exists(dest_path):
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": f"{Path(dest_path).name} already exists in uploads, delete it first",
|
||||||
|
}), 400
|
||||||
|
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
_copy_file_with_progress(src_path, dest_path)
|
||||||
|
except Exception as e:
|
||||||
|
with _copy_lock:
|
||||||
|
err = _copy_progress.get("error") or str(e)
|
||||||
|
return jsonify({"success": False, "error": f"Copy failed: {err}"}), 500
|
||||||
|
upload_path = dest_path
|
||||||
|
|
||||||
|
with _recount_lock:
|
||||||
|
_stop_recount_locked()
|
||||||
|
_current_file = upload_path
|
||||||
|
try:
|
||||||
|
_spawn_recount_locked(upload_path)
|
||||||
|
except Exception as e:
|
||||||
|
_current_file = None
|
||||||
|
return jsonify({"success": False, "error": f"Failed to start: {e}"}), 500
|
||||||
|
|
||||||
|
_reset_recount_api()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"stream_url": "/api/proxy-stream",
|
"stream_url": "/api/proxy-stream",
|
||||||
@@ -202,13 +395,41 @@ def start_recount():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/start-recount", methods=["POST"])
|
||||||
|
def start_recount():
|
||||||
|
with _recount_lock:
|
||||||
|
current = _current_file
|
||||||
|
if not current:
|
||||||
|
return jsonify({"success": False, "error": "Load an MP4 first"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
Path(SHM_DIR).mkdir(parents=True, exist_ok=True)
|
||||||
|
_CONTINUE_FLAG.touch(exist_ok=True)
|
||||||
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Recount continue: {_CONTINUE_FLAG}")
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": f"Failed to signal: {e}"}), 500
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"stream_url": "/api/proxy-stream",
|
||||||
|
"file": Path(current).name,
|
||||||
|
"path": current,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/stop-recount", methods=["POST"])
|
@app.route("/api/stop-recount", methods=["POST"])
|
||||||
def stop_recount():
|
def stop_recount():
|
||||||
global _current_file
|
global _current_file
|
||||||
with _recount_lock:
|
with _recount_lock:
|
||||||
_stop_recount_locked()
|
_stop_recount_locked()
|
||||||
_current_file = None
|
_current_file = None
|
||||||
return jsonify({"success": True})
|
killed, remaining = _sweep_recount_processes(_recount_binary_name())
|
||||||
|
stamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
if remaining:
|
||||||
|
print(f"[{stamp}] Recount stop: killed {killed}, remaining {remaining} (SIGKILL could not reap)")
|
||||||
|
else:
|
||||||
|
print(f"[{stamp}] Recount stop: killed {killed}, remaining {remaining}")
|
||||||
|
return jsonify({"success": True, "killed": killed, "remaining": remaining})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/state")
|
@app.route("/api/state")
|
||||||
@@ -313,5 +534,6 @@ if __name__ == "__main__":
|
|||||||
print(f"Live API: {LIVE_API_URL}")
|
print(f"Live API: {LIVE_API_URL}")
|
||||||
print(f"Recount API: {RECOUNT_API_URL}")
|
print(f"Recount API: {RECOUNT_API_URL}")
|
||||||
print(f"Upload dir: {UPLOAD_DIR}")
|
print(f"Upload dir: {UPLOAD_DIR}")
|
||||||
|
print(f"Output dir: {OUTPUT_DIR}")
|
||||||
print(f"Recount command: {RECOUNT_CMD}")
|
print(f"Recount command: {RECOUNT_CMD}")
|
||||||
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
|
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
|
||||||
@@ -575,6 +575,7 @@
|
|||||||
<span class="date-val" id="current-date">--</span>
|
<span class="date-val" id="current-date">--</span>
|
||||||
Counting Day
|
Counting Day
|
||||||
</div>
|
</div>
|
||||||
|
{% if upload_dashboard_url %}<a class="live-chip" href="{{ upload_dashboard_url }}" style="text-decoration:none;">RECOUNT UPLOAD</a>{% endif %}
|
||||||
<div class="live-chip">
|
<div class="live-chip">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span>{{ site_name }}</span>
|
<span>{{ site_name }}</span>
|
||||||
|
|||||||
@@ -408,6 +408,7 @@
|
|||||||
<span class="date-val" id="current-date">--</span>
|
<span class="date-val" id="current-date">--</span>
|
||||||
Counting Day
|
Counting Day
|
||||||
</div>
|
</div>
|
||||||
|
{% if upload_dashboard_url %}<a class="live-chip" href="{{ upload_dashboard_url }}" style="text-decoration:none;">RECOUNT UPLOAD</a>{% endif %}
|
||||||
<div class="live-chip">
|
<div class="live-chip">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span>{{ site_name }}</span>
|
<span>{{ site_name }}</span>
|
||||||
|
|||||||
@@ -418,6 +418,7 @@
|
|||||||
<span class="date-val" id="current-date">--</span>
|
<span class="date-val" id="current-date">--</span>
|
||||||
Counting Day
|
Counting Day
|
||||||
</div>
|
</div>
|
||||||
|
{% if upload_dashboard_url %}<a class="live-chip" href="{{ upload_dashboard_url }}" style="text-decoration:none;">RECOUNT UPLOAD</a>{% endif %}
|
||||||
<div class="live-chip">
|
<div class="live-chip">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span>{{ site_name }}</span>
|
<span>{{ site_name }}</span>
|
||||||
|
|||||||
+27
-24
@@ -449,6 +449,7 @@
|
|||||||
let selectedFile = null;
|
let selectedFile = null;
|
||||||
let streaming = false;
|
let streaming = false;
|
||||||
let streamUrl = '';
|
let streamUrl = '';
|
||||||
|
let previewAutoOn = false;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadRecountProgress();
|
loadRecountProgress();
|
||||||
@@ -471,37 +472,23 @@ async function syncState() {
|
|||||||
function applyState(data) {
|
function applyState(data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
if (data.file) {
|
|
||||||
selectedFile = data.file;
|
|
||||||
document.getElementById('btn-start').disabled = data.streaming;
|
|
||||||
document.getElementById('btn-download').disabled = false;
|
|
||||||
} else {
|
|
||||||
selectedFile = null;
|
|
||||||
batchResultLoaded = false;
|
|
||||||
document.getElementById('btn-start').disabled = true;
|
|
||||||
document.getElementById('btn-download').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) {
|
if (data.streaming && data.file) {
|
||||||
streaming = true;
|
streaming = true;
|
||||||
streamUrl = data.stream_url;
|
streamUrl = data.stream_url;
|
||||||
|
selectedFile = data.file;
|
||||||
document.getElementById('btn-stop').disabled = false;
|
document.getElementById('btn-stop').disabled = false;
|
||||||
document.getElementById('btn-start').disabled = true;
|
document.getElementById('btn-start').disabled = true;
|
||||||
|
document.getElementById('btn-download').disabled = false;
|
||||||
document.getElementById('btn-start').textContent = 'STREAMING';
|
document.getElementById('btn-start').textContent = 'STREAMING';
|
||||||
document.getElementById('btn-start').classList.add('btn-ghost');
|
document.getElementById('btn-start').classList.add('btn-ghost');
|
||||||
document.getElementById('video-status').innerHTML =
|
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 || '');
|
'<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';
|
document.getElementById('video-status').className = 'video-status connected';
|
||||||
|
if (!previewAutoOn) enablePreview();
|
||||||
|
|
||||||
loadBatchResult(data.file);
|
loadBatchResult(data.file);
|
||||||
} else if (!data.streaming && data.file) {
|
} else if (data.file && selectedFile === data.file) {
|
||||||
|
previewAutoOn = false;
|
||||||
loadBatchResult(data.file);
|
loadBatchResult(data.file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -685,6 +672,7 @@ async function startRecount() {
|
|||||||
document.getElementById('video-status').innerHTML =
|
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;
|
'<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';
|
document.getElementById('video-status').className = 'video-status connected';
|
||||||
|
enablePreview();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error: ' + err.message);
|
alert('Error: ' + err.message);
|
||||||
btnStart.disabled = false;
|
btnStart.disabled = false;
|
||||||
@@ -702,6 +690,7 @@ async function stopRecount() {
|
|||||||
streaming = false;
|
streaming = false;
|
||||||
streamUrl = '';
|
streamUrl = '';
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
|
previewAutoOn = false;
|
||||||
batchResultLoaded = false;
|
batchResultLoaded = false;
|
||||||
|
|
||||||
const img = document.getElementById('stream-img');
|
const img = document.getElementById('stream-img');
|
||||||
@@ -748,11 +737,7 @@ function togglePreview() {
|
|||||||
btn.textContent = 'STREAM OFF';
|
btn.textContent = 'STREAM OFF';
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
} else {
|
} else {
|
||||||
img.src = streamUrl + '?' + Date.now();
|
enablePreview();
|
||||||
img.style.display = 'block';
|
|
||||||
if (placeholder) placeholder.style.display = 'none';
|
|
||||||
btn.textContent = 'STREAM ON';
|
|
||||||
btn.classList.add('active');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,6 +752,24 @@ function resetVideoToggle() {
|
|||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enablePreview() {
|
||||||
|
const btn = document.getElementById('videoToggle');
|
||||||
|
const img = document.getElementById('stream-img');
|
||||||
|
const 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');
|
||||||
|
}
|
||||||
|
|
||||||
let lastRecountCount = null;
|
let lastRecountCount = null;
|
||||||
let lastRecountTime = null;
|
let lastRecountTime = null;
|
||||||
|
|
||||||
|
|||||||
@@ -371,6 +371,7 @@
|
|||||||
let selectedFile = null;
|
let selectedFile = null;
|
||||||
let streaming = false;
|
let streaming = false;
|
||||||
let streamUrl = '';
|
let streamUrl = '';
|
||||||
|
let previewAutoOn = false;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadRecountProgress();
|
loadRecountProgress();
|
||||||
@@ -393,25 +394,23 @@ async function syncState() {
|
|||||||
function applyState(data) {
|
function applyState(data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
if (data.file) {
|
|
||||||
selectedFile = data.file;
|
|
||||||
document.getElementById('btn-start').disabled = data.streaming;
|
|
||||||
document.getElementById('btn-download').disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.streaming && data.file) {
|
if (data.streaming && data.file) {
|
||||||
streaming = true;
|
streaming = true;
|
||||||
streamUrl = data.stream_url;
|
streamUrl = data.stream_url;
|
||||||
|
selectedFile = data.file;
|
||||||
document.getElementById('btn-stop').disabled = false;
|
document.getElementById('btn-stop').disabled = false;
|
||||||
document.getElementById('btn-start').disabled = true;
|
document.getElementById('btn-start').disabled = true;
|
||||||
|
document.getElementById('btn-download').disabled = false;
|
||||||
document.getElementById('btn-start').textContent = 'STREAMING';
|
document.getElementById('btn-start').textContent = 'STREAMING';
|
||||||
document.getElementById('btn-start').classList.add('btn-ghost');
|
document.getElementById('btn-start').classList.add('btn-ghost');
|
||||||
document.getElementById('video-status').innerHTML =
|
document.getElementById('video-status').innerHTML =
|
||||||
'<span style="width:5px;height:5px;background:var(--success);display:inline-block;"></span> streaming ' + (data.file_name || '');
|
'<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';
|
document.getElementById('video-status').className = 'video-status connected';
|
||||||
|
if (!previewAutoOn) enablePreview();
|
||||||
|
|
||||||
loadBatchResult(data.file);
|
loadBatchResult(data.file);
|
||||||
} else if (!data.streaming && data.file) {
|
} else if (data.file && selectedFile === data.file) {
|
||||||
|
previewAutoOn = false;
|
||||||
loadBatchResult(data.file);
|
loadBatchResult(data.file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,6 +598,7 @@ async function startRecount() {
|
|||||||
document.getElementById('video-status').innerHTML =
|
document.getElementById('video-status').innerHTML =
|
||||||
'<span style="width:5px;height:5px;background:var(--success);display:inline-block;"></span> streaming ' + data.file;
|
'<span style="width:5px;height:5px;background:var(--success);display:inline-block;"></span> streaming ' + data.file;
|
||||||
document.getElementById('video-status').className = 'video-status connected';
|
document.getElementById('video-status').className = 'video-status connected';
|
||||||
|
enablePreview();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error: ' + err.message);
|
alert('Error: ' + err.message);
|
||||||
btnStart.disabled = false;
|
btnStart.disabled = false;
|
||||||
@@ -613,6 +613,7 @@ async function stopRecount() {
|
|||||||
streaming = false;
|
streaming = false;
|
||||||
streamUrl = '';
|
streamUrl = '';
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
|
previewAutoOn = false;
|
||||||
batchResultLoaded = false;
|
batchResultLoaded = false;
|
||||||
|
|
||||||
const img = document.getElementById('stream-img');
|
const img = document.getElementById('stream-img');
|
||||||
@@ -659,11 +660,7 @@ function togglePreview() {
|
|||||||
btn.textContent = 'STREAM OFF';
|
btn.textContent = 'STREAM OFF';
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
} else {
|
} else {
|
||||||
img.src = streamUrl + '?' + Date.now();
|
enablePreview();
|
||||||
img.style.display = 'block';
|
|
||||||
if (placeholder) placeholder.style.display = 'none';
|
|
||||||
btn.textContent = 'STREAM ON';
|
|
||||||
btn.classList.add('active');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,6 +675,24 @@ function resetVideoToggle() {
|
|||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enablePreview() {
|
||||||
|
const btn = document.getElementById('videoToggle');
|
||||||
|
const img = document.getElementById('stream-img');
|
||||||
|
const 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');
|
||||||
|
}
|
||||||
|
|
||||||
// --- Recount counter ---
|
// --- Recount counter ---
|
||||||
|
|
||||||
let lastRecountCount = null;
|
let lastRecountCount = null;
|
||||||
|
|||||||
@@ -301,6 +301,38 @@
|
|||||||
.file-list {
|
.file-list {
|
||||||
flex: 1; overflow-y: auto; max-height: 300px;
|
flex: 1; overflow-y: auto; max-height: 300px;
|
||||||
}
|
}
|
||||||
|
.output-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;
|
||||||
|
}
|
||||||
|
.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: 4px;
|
||||||
|
background: var(--cell-bg);
|
||||||
|
border: 1px solid var(--cell-border); border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px; letter-spacing: 1px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.folder-header:hover { border-color: var(--border-glow); background: var(--card-bg); }
|
||||||
|
.folder-header .folder-arrow {
|
||||||
|
display: inline-block; color: var(--accent2); 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 {
|
.file-item {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 10px 12px; margin-bottom: 4px;
|
padding: 10px 12px; margin-bottom: 4px;
|
||||||
@@ -432,6 +464,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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">
|
<div class="live-chip live-chip-green">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span>LIVE</span>
|
<span>LIVE</span>
|
||||||
@@ -483,7 +516,7 @@
|
|||||||
<div class="video-container" id="video-container">
|
<div class="video-container" id="video-container">
|
||||||
<div class="video-placeholder" id="video-placeholder">
|
<div class="video-placeholder" id="video-placeholder">
|
||||||
<div class="vp-icon">▶</div>
|
<div class="vp-icon">▶</div>
|
||||||
<div class="vp-text">Upload an MP4 and start recount</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 class="vp-hint">Preview via recount node live feed</div>
|
||||||
</div>
|
</div>
|
||||||
<img id="stream-img" alt="Stream" style="display:none;" />
|
<img id="stream-img" alt="Stream" style="display:none;" />
|
||||||
@@ -524,6 +557,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-row">
|
<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>
|
<button class="btn btn-primary" id="btn-start" onclick="startRecount()" disabled>
|
||||||
▶ START RECOUNT
|
▶ START RECOUNT
|
||||||
</button>
|
</button>
|
||||||
@@ -540,6 +576,20 @@
|
|||||||
</div>
|
</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(--text-secondary);text-align:center;padding:20px;">
|
||||||
|
No files found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="footer">{{ site_name }} Recounting Upload Dashboard</div>
|
<div class="footer">{{ site_name }} Recounting Upload Dashboard</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -547,16 +597,21 @@
|
|||||||
let selectedFile = null;
|
let selectedFile = null;
|
||||||
let streaming = false;
|
let streaming = false;
|
||||||
let streamUrl = '';
|
let streamUrl = '';
|
||||||
|
let previewAutoOn = false;
|
||||||
|
let loaded = false;
|
||||||
|
let started = false;
|
||||||
let pendingUploadFile = null;
|
let pendingUploadFile = null;
|
||||||
let batchResultLoaded = false;
|
let batchResultLoaded = false;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadRecountProgress();
|
loadRecountProgress();
|
||||||
loadUploads();
|
loadUploads();
|
||||||
|
loadOutputFiles();
|
||||||
syncState();
|
syncState();
|
||||||
|
|
||||||
setInterval(loadRecountProgress, 500);
|
setInterval(loadRecountProgress, 500);
|
||||||
setInterval(loadUploads, 30000);
|
setInterval(loadUploads, 30000);
|
||||||
|
setInterval(loadOutputFiles, 30000);
|
||||||
setInterval(syncState, 1000);
|
setInterval(syncState, 1000);
|
||||||
|
|
||||||
var drop = document.getElementById('upload-drop');
|
var drop = document.getElementById('upload-drop');
|
||||||
@@ -638,9 +693,7 @@ function uploadFile() {
|
|||||||
}
|
}
|
||||||
showUploadStatus('Upload complete: ' + data.name, 'success');
|
showUploadStatus('Upload complete: ' + data.name, 'success');
|
||||||
selectedFile = data.path;
|
selectedFile = data.path;
|
||||||
document.getElementById('btn-start').disabled = false;
|
renderButtons();
|
||||||
document.getElementById('btn-download').disabled = false;
|
|
||||||
document.getElementById('btn-delete').disabled = false;
|
|
||||||
document.getElementById('pending-filename').textContent = 'no file chosen';
|
document.getElementById('pending-filename').textContent = 'no file chosen';
|
||||||
document.getElementById('pending-filename').classList.remove('has-file');
|
document.getElementById('pending-filename').classList.remove('has-file');
|
||||||
pendingUploadFile = null;
|
pendingUploadFile = null;
|
||||||
@@ -676,9 +729,7 @@ function selectFile(path, name) {
|
|||||||
if (selectedFile === path) {
|
if (selectedFile === path) {
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
batchResultLoaded = false;
|
batchResultLoaded = false;
|
||||||
document.getElementById('btn-start').disabled = true;
|
renderButtons();
|
||||||
document.getElementById('btn-download').disabled = true;
|
|
||||||
document.getElementById('btn-delete').disabled = true;
|
|
||||||
document.getElementById('live-title').innerHTML = '◉ Live Counter';
|
document.getElementById('live-title').innerHTML = '◉ Live Counter';
|
||||||
document.getElementById('live-count').textContent = '--';
|
document.getElementById('live-count').textContent = '--';
|
||||||
document.getElementById('live-label').textContent = 'Objects Counted';
|
document.getElementById('live-label').textContent = 'Objects Counted';
|
||||||
@@ -690,9 +741,7 @@ function selectFile(path, name) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectedFile = path;
|
selectedFile = path;
|
||||||
document.getElementById('btn-start').disabled = false;
|
renderButtons();
|
||||||
document.getElementById('btn-download').disabled = false;
|
|
||||||
document.getElementById('btn-delete').disabled = false;
|
|
||||||
updateFileSelection();
|
updateFileSelection();
|
||||||
loadBatchResult(path);
|
loadBatchResult(path);
|
||||||
}
|
}
|
||||||
@@ -738,6 +787,52 @@ async function loadUploads() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(--text-secondary);text-align:center;padding:20px;">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(--accent2);"></i>' +
|
||||||
|
'<span>' + g.folder + '</span>' +
|
||||||
|
'<span style="font-size:8px;color:var(--text-secondary);">(' + 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) {
|
function formatSize(bytes) {
|
||||||
if (bytes < 1024) return bytes + ' B';
|
if (bytes < 1024) return bytes + ' B';
|
||||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
@@ -745,8 +840,103 @@ function formatSize(bytes) {
|
|||||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startRecount() {
|
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;
|
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');
|
var btnStart = document.getElementById('btn-start');
|
||||||
btnStart.disabled = true;
|
btnStart.disabled = true;
|
||||||
btnStart.textContent = 'CONNECTING...';
|
btnStart.textContent = 'CONNECTING...';
|
||||||
@@ -760,31 +950,28 @@ async function startRecount() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
var res = await fetch('/api/start-recount', {
|
var res = await fetch('/api/start-recount', {
|
||||||
method: 'POST',
|
method: 'POST'
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ path: selectedFile })
|
|
||||||
});
|
});
|
||||||
var data = await res.json();
|
var data = await res.json();
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
alert('Failed: ' + (data.error || 'unknown'));
|
alert('Failed: ' + (data.error || 'unknown'));
|
||||||
btnStart.disabled = false;
|
renderButtons();
|
||||||
btnStart.textContent = '\u25b6 START RECOUNT';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
streamUrl = data.stream_url;
|
streamUrl = data.stream_url;
|
||||||
streaming = true;
|
streaming = true;
|
||||||
selectedFile = data.path;
|
selectedFile = data.path;
|
||||||
|
loaded = true;
|
||||||
|
started = true;
|
||||||
|
|
||||||
document.getElementById('btn-stop').disabled = false;
|
renderButtons();
|
||||||
btnStart.textContent = 'STREAMING';
|
|
||||||
btnStart.classList.add('btn-ghost');
|
|
||||||
document.getElementById('video-status').innerHTML =
|
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;
|
'<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';
|
document.getElementById('video-status').className = 'video-status connected';
|
||||||
|
enablePreview();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error: ' + err.message);
|
alert('Error: ' + err.message);
|
||||||
btnStart.disabled = false;
|
renderButtons();
|
||||||
btnStart.textContent = '\u25b6 START RECOUNT';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,6 +985,9 @@ async function stopRecount() {
|
|||||||
streaming = false;
|
streaming = false;
|
||||||
streamUrl = '';
|
streamUrl = '';
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
|
previewAutoOn = false;
|
||||||
|
loaded = false;
|
||||||
|
started = false;
|
||||||
batchResultLoaded = false;
|
batchResultLoaded = false;
|
||||||
|
|
||||||
var img = document.getElementById('stream-img');
|
var img = document.getElementById('stream-img');
|
||||||
@@ -806,15 +996,7 @@ async function stopRecount() {
|
|||||||
img.style.display = 'none';
|
img.style.display = 'none';
|
||||||
if (placeholder) placeholder.style.display = '';
|
if (placeholder) placeholder.style.display = '';
|
||||||
|
|
||||||
var btnStart = document.getElementById('btn-start');
|
renderButtons();
|
||||||
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 =
|
document.getElementById('video-status').innerHTML =
|
||||||
'<span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></span> stopped';
|
'<span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></span> stopped';
|
||||||
@@ -844,11 +1026,7 @@ function togglePreview() {
|
|||||||
btn.textContent = 'STREAM OFF';
|
btn.textContent = 'STREAM OFF';
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
} else {
|
} else {
|
||||||
img.src = streamUrl + '?' + Date.now();
|
enablePreview();
|
||||||
img.style.display = 'block';
|
|
||||||
if (placeholder) placeholder.style.display = 'none';
|
|
||||||
btn.textContent = 'STREAM ON';
|
|
||||||
btn.classList.add('active');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,6 +1041,24 @@ function resetVideoToggle() {
|
|||||||
btn.classList.remove('active');
|
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() {
|
async function syncState() {
|
||||||
try {
|
try {
|
||||||
var res = await fetch('/api/state');
|
var res = await fetch('/api/state');
|
||||||
@@ -873,38 +1069,30 @@ async function syncState() {
|
|||||||
|
|
||||||
function applyState(data) {
|
function applyState(data) {
|
||||||
if (!data) return;
|
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) {
|
if (data.streaming && data.file) {
|
||||||
streaming = true;
|
streaming = true;
|
||||||
streamUrl = data.stream_url;
|
streamUrl = data.stream_url;
|
||||||
document.getElementById('btn-stop').disabled = false;
|
selectedFile = data.file;
|
||||||
document.getElementById('btn-start').disabled = true;
|
loaded = true;
|
||||||
document.getElementById('btn-start').textContent = 'STREAMING';
|
renderButtons();
|
||||||
document.getElementById('btn-start').classList.add('btn-ghost');
|
|
||||||
document.getElementById('video-status').innerHTML =
|
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 || '');
|
'<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';
|
document.getElementById('video-status').className = 'video-status connected';
|
||||||
|
if (!previewAutoOn) enablePreview();
|
||||||
|
|
||||||
loadBatchResult(data.file);
|
loadBatchResult(data.file);
|
||||||
} else if (!data.streaming && data.file) {
|
} else if (data.file && selectedFile === data.file) {
|
||||||
|
previewAutoOn = false;
|
||||||
|
loaded = false;
|
||||||
|
started = false;
|
||||||
|
renderButtons();
|
||||||
loadBatchResult(data.file);
|
loadBatchResult(data.file);
|
||||||
|
} else if (!data.streaming) {
|
||||||
|
previewAutoOn = false;
|
||||||
|
loaded = false;
|
||||||
|
started = false;
|
||||||
|
renderButtons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,9 +1155,7 @@ async function deleteUploadRecord(fpath) {
|
|||||||
if (selectedFile === fpath) {
|
if (selectedFile === fpath) {
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
batchResultLoaded = false;
|
batchResultLoaded = false;
|
||||||
document.getElementById('btn-start').disabled = true;
|
renderButtons();
|
||||||
document.getElementById('btn-download').disabled = true;
|
|
||||||
document.getElementById('btn-delete').disabled = true;
|
|
||||||
document.getElementById('live-title').innerHTML = '◉ Live Counter';
|
document.getElementById('live-title').innerHTML = '◉ Live Counter';
|
||||||
document.getElementById('live-count').textContent = '--';
|
document.getElementById('live-count').textContent = '--';
|
||||||
document.getElementById('live-label').textContent = 'Objects Counted';
|
document.getElementById('live-label').textContent = 'Objects Counted';
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user