From fccc285ab3d46ae092e485f0d20309f268270d3d Mon Sep 17 00:00:00 2001 From: dsutanto Date: Thu, 13 Aug 2026 13:33:26 +0700 Subject: [PATCH] Add OUTPUT_DIR browser + copy-on-load with progress to upload dashboard LOAD MP4 on an OUTPUT_DIR file copies it into UPLOAD_DIR (chunked, progress via /api/copy-progress) before spawning RECOUNT_CMD; collision in UPLOAD_DIR fails with 400. New read-only folder-grouped browser mirrors recounting_dashboard.py. Docs updated. --- AGENTS.md | 1 + SPEC.md | 8 +- config.env.example | 3 + recounting_dashboard_upload.py | 115 +++++++++++++++- templates/recounting_upload.html | 130 ++++++++++++++++++- templates/recounting_upload_lamborghini.html | 128 +++++++++++++++++- 6 files changed, 377 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5732125..8759919 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ PYTHONNOUSERSITE=1 venv/bin/python recounting_dashboard_upload.py - `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. - **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/` for the recorded count. - **`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). diff --git a/SPEC.md b/SPEC.md index 57d52ba..a81a45c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -69,6 +69,7 @@ env: GO2RTC_STREAM_NAME → go2rtc stream name for recount preview (default "rec ``` env: RECOUNTING_UPLOAD_PORT → port for upload UI (default 5003) env: UPLOAD_DIR → dir for uploaded MP4s (default /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: RECOUNTING_UPLOAD_TEMPLATE → template (default recounting_upload_lamborghini.html) env: LIVE_DASHBOARD_URL → header link to live counter dashboard (default empty → hidden) @@ -123,8 +124,10 @@ api: POST /api/stop-recount → kill ffmpeg + clear current file 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: 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/load-mp4 {path} → kill old process, spawn RECOUNT_CMD (path shlex-quoted) paused, then reset recount API +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/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} @@ -163,6 +166,8 @@ V21: upload filename batch_XX_YYYYMMDD_HHmmSS.mp4 → batch# + date parsed; batc 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 @@ -188,6 +193,7 @@ T16|x|add cross-dashboard header links — UPLOAD_DASHBOARD_URL (live→upload) 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 diff --git a/config.env.example b/config.env.example index 31e16cb..58e93bd 100644 --- a/config.env.example +++ b/config.env.example @@ -185,6 +185,9 @@ RECOUNT_STREAM_NAME=stream_1 RECOUNTING_UPLOAD_PORT=5003 # Directory where uploaded MP4 files are stored UPLOAD_DIR=/opt/bytetrack-counter/uploads +# Directory scanned for the "From Output Directory" file browser (folder-grouped MP4s). +# Selecting one of those files and pressing LOAD MP4 copies it into UPLOAD_DIR first. +OUTPUT_DIR=/opt/bytetrack-counter # Command launched by LOAD MP4 (bytetrack-counter-cpp); {path} is the uploaded MP4. # The process loads + pauses until START RECOUNT touches {SHM_DIR}/.continue. RECOUNT_CMD=bytetrack-counter-cpp config.env --source {path} diff --git a/recounting_dashboard_upload.py b/recounting_dashboard_upload.py index ef85ca0..34f237d 100644 --- a/recounting_dashboard_upload.py +++ b/recounting_dashboard_upload.py @@ -8,6 +8,7 @@ import logging import os import re import shlex +import shutil import signal import subprocess import threading @@ -32,6 +33,7 @@ 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"))) +OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/opt/bytetrack-counter")) LIVE_API_URL = os.getenv("LIVE_API_URL", "http://localhost:5000") RECOUNT_API_URL = os.getenv("RECOUNT_API_URL", "http://localhost:5001") @@ -60,6 +62,9 @@ _recount_proc = None _recount_lock = threading.Lock() _current_file = None +_copy_progress = {} +_copy_lock = threading.Lock() + def _parse_filename(filename): m = _FILENAME_RE.search(filename) @@ -172,7 +177,7 @@ def _folder_date(filepath, base): @app.route("/") def index(): - return render_template(TEMPLATE, site_name=SITE_NAME, upload_dir=str(UPLOAD_DIR), live_dashboard_url=LIVE_DASHBOARD_URL) + 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") @@ -206,6 +211,55 @@ def api_uploads(): 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"]) def api_upload(): if "file" not in request.files: @@ -262,15 +316,65 @@ def _spawn_recount_locked(upload_path): 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 {} - upload_path = data.get("path", "") - if not upload_path: + src_path = data.get("path", "") + if not src_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 + 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() @@ -430,5 +534,6 @@ if __name__ == "__main__": print(f"Live API: {LIVE_API_URL}") print(f"Recount API: {RECOUNT_API_URL}") print(f"Upload dir: {UPLOAD_DIR}") + print(f"Output dir: {OUTPUT_DIR}") print(f"Recount command: {RECOUNT_CMD}") app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG) diff --git a/templates/recounting_upload.html b/templates/recounting_upload.html index a869f7c..453fdc4 100644 --- a/templates/recounting_upload.html +++ b/templates/recounting_upload.html @@ -301,6 +301,38 @@ .file-list { 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 { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; margin-bottom: 4px; @@ -544,6 +576,20 @@ +
+
+ ◉ From Output Directory + +
+
+
+ No files found +
+
+
+ @@ -560,10 +606,12 @@ let batchResultLoaded = false; document.addEventListener('DOMContentLoaded', () => { loadRecountProgress(); loadUploads(); + loadOutputFiles(); syncState(); setInterval(loadRecountProgress, 500); setInterval(loadUploads, 30000); + setInterval(loadOutputFiles, 30000); setInterval(syncState, 1000); var drop = document.getElementById('upload-drop'); @@ -739,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 = '
No MP4 files found
'; + return; + } + container.innerHTML = folders.map(function(g, gi) { + var fid = 'ofolder-' + gi; + return '
' + + '▶' + + '' + + '' + g.folder + '' + + '(' + g.files.length + ')' + + '
' + + '
' + + 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 '
' + + '' + (str ? '◉' : '▶') + '' + + '' + f.name + '' + + '' + formatSize(f.size) + '' + + '
'; + }).join('') + + '
'; + }).join(''); + } catch (err) { + console.error('Failed to load output files:', err); + } +} + function formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; @@ -779,13 +873,29 @@ async function loadMp4() { var btnLoad = document.getElementById('btn-load'); btnLoad.disabled = true; btnLoad.textContent = 'LOADING...'; + var pollTimer = null; try { - var res = await fetch('/api/load-mp4', { + 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(); @@ -800,6 +910,8 @@ async function loadMp4() { renderButtons(); syncState(); } catch (err) { + if (pollTimer) clearInterval(pollTimer); + hideCopyProgress(); alert('Error: ' + err.message); renderButtons(); } finally { @@ -807,6 +919,22 @@ async function loadMp4() { } } +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'); diff --git a/templates/recounting_upload_lamborghini.html b/templates/recounting_upload_lamborghini.html index b1bf6f9..974ff85 100644 --- a/templates/recounting_upload_lamborghini.html +++ b/templates/recounting_upload_lamborghini.html @@ -204,6 +204,36 @@ .file-list { flex: 1; overflow-y: auto; max-height: 300px; } + .output-panel { + background: var(--surface); + padding: 24px; + display: flex; flex-direction: column; + } + .output-panel .fp-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 12px; + } + .output-list { + flex: 1; overflow-y: auto; max-height: 300px; + } + .folder-header { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; margin-bottom: 2px; + background: var(--surface2); + border: 1px solid var(--divider); + cursor: pointer; + font-size: 10px; letter-spacing: 2px; text-transform: uppercase; + color: var(--white); + transition: border-color 0.2s, background 0.2s; + } + .folder-header:hover { border-color: var(--gold); } + .folder-header .folder-arrow { + display: inline-block; color: var(--gold); font-size: 10px; + transition: transform 0.2s; + } + .folder-header.open .folder-arrow { transform: rotate(90deg); } + .folder-body { display: none; padding-left: 8px; } + .folder-body.open { display: block; } .file-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; margin-bottom: 2px; @@ -423,6 +453,20 @@ +
+
+ From Output Directory + +
+
+
+ No files found +
+
+
+ @@ -439,10 +483,12 @@ let batchResultLoaded = false; document.addEventListener('DOMContentLoaded', () => { loadRecountProgress(); loadUploads(); + loadOutputFiles(); syncState(); setInterval(loadRecountProgress, 500); setInterval(loadUploads, 30000); + setInterval(loadOutputFiles, 30000); setInterval(syncState, 1000); var drop = document.getElementById('upload-drop'); @@ -618,6 +664,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 = '
No MP4 files found
'; + return; + } + container.innerHTML = folders.map(function(g, gi) { + var fid = 'ofolder-' + gi; + return '
' + + '▶' + + '' + + '' + g.folder + '' + + '(' + g.files.length + ')' + + '
' + + '
' + + 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 '
' + + '' + (str ? '◉' : '▶') + '' + + '' + f.name + '' + + '' + formatSize(f.size) + '' + + '
'; + }).join('') + + '
'; + }).join(''); + } catch (err) { + console.error('Failed to load output files:', err); + } +} + function formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; @@ -658,13 +750,29 @@ async function loadMp4() { var btnLoad = document.getElementById('btn-load'); btnLoad.disabled = true; btnLoad.textContent = 'LOADING...'; + var pollTimer = null; try { - var res = await fetch('/api/load-mp4', { + 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(); @@ -679,6 +787,8 @@ async function loadMp4() { renderButtons(); syncState(); } catch (err) { + if (pollTimer) clearInterval(pollTimer); + hideCopyProgress(); alert('Error: ' + err.message); renderButtons(); } finally { @@ -686,6 +796,22 @@ async function loadMp4() { } } +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');