From 194c94b872f2bcd42d5e5feafda094662b461a95 Mon Sep 17 00:00:00 2001 From: Flag Date: Wed, 22 Apr 2026 23:00:11 +0000 Subject: [PATCH] =?UTF-8?q?dashboard=20=C2=97=20thumb=2048x27,=20step=20li?= =?UTF-8?q?ve,=20spin=20busy,=20live=20thumbnail=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dispatcher: col step ajoutee (migration). set a chaque phase: scp N/M, ffmpeg N/M, trimming hors-eau, reconstruct demo.py - _refresh_thumbnail() scp la DERNIERE frame extraite toutes les ~15s pendant ffmpeg pour que le preview colle a la progression live - template: cache-bust thumbnail via ?t=mtime, step affiche sous progress bar, thumb revenue a 48x27 - CSS: .badge.busy -> animation spin (rotation infinie) au lieu de juste une couleur, .step-text italique mute --- app/main.py | 10 +++- app/static/style.css | 10 +++- app/templates/_jobs_table.html | 9 ++- .../__pycache__/dispatcher.cpython-311.pyc | Bin 45287 -> 47943 bytes scripts/dispatcher.py | 55 +++++++++++------- 5 files changed, 56 insertions(+), 28 deletions(-) diff --git a/app/main.py b/app/main.py index 478b11c..b698938 100644 --- a/app/main.py +++ b/app/main.py @@ -195,7 +195,15 @@ def _build_acquisitions(): d["gp_label"] = gp_label.get((j["acquisition_id"], j["auv"], j["gopro_serial"]), "?") d["video_duration_fmt"] = _fmt_dur(int(j["video_duration_s"] or 0)) if (j["video_duration_s"] or 0) > 0 else "—" d["trimmed_total"] = (j["trimmed_head"] or 0) + (j["trimmed_tail"] or 0) - d["has_thumbnail"] = (DB_PATH.parent / "thumbnails" / f"job_{j['id']}.jpg").exists() + thumb_path = DB_PATH.parent / "thumbnails" / f"job_{j['id']}.jpg" + d["has_thumbnail"] = thumb_path.exists() + # Bust the browser cache on the mtime so the preview refreshes as the dispatcher re-copies it. + d["thumb_ts"] = int(thumb_path.stat().st_mtime) if d["has_thumbnail"] else 0 + # Try the new column; fall back silently on old rows. + try: + d["step"] = j["step"] + except (KeyError, IndexError): + d["step"] = None # Mask the viser link when the demo.py that was serving it has since died. if j["status"] == "done" and j["viser_url"] and not _viser_alive(j["viser_url"]): d["viser_url"] = None diff --git a/app/static/style.css b/app/static/style.css index b7e53c3..e66608a 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -126,9 +126,9 @@ code { background: rgba(255,255,255,0.05); padding: 0 0.25rem; border-radius: 3p .jobs-table tbody tr:hover { background: rgba(255,255,255,0.02); } .col-status { width: 24px; text-align: center; } -.col-thumb { width: 80px; } -.col-thumb img { width: 72px; height: 40px; object-fit: cover; border-radius: 4px; display: block; } -.thumb-placeholder { display: inline-block; width: 72px; height: 40px; background: rgba(255,255,255,0.04); border-radius: 4px; } +.col-thumb { width: 56px; } +.col-thumb img { width: 48px; height: 27px; object-fit: cover; border-radius: 3px; display: block; } +.thumb-placeholder { display: inline-block; width: 48px; height: 27px; background: rgba(255,255,255,0.04); border-radius: 3px; } .col-id { font-family: ui-monospace, monospace; color: var(--muted); font-size: 0.75rem; } .col-auv { min-width: 110px; } .col-seg { font-variant-numeric: tabular-nums; color: var(--muted); } @@ -157,3 +157,7 @@ code { background: rgba(255,255,255,0.05); padding: 0 0.25rem; border-radius: 3p .stitch-list { list-style: none; padding: 0; margin: 0; } .stitch-item { display: flex; align-items: center; gap: 0.5rem; padding: 3px 0; font-size: 0.82rem; } .stitch-item .err-line { flex-basis: 100%; margin-left: 26px; color: var(--err, #d44); font-size: 0.72rem; } + +.badge.busy { display: inline-block; animation: spin 1.2s linear infinite; transform-origin: 50% 50%; } +.progress-wrap { display: flex; align-items: center; gap: 6px; } +.step-text { margin-top: 2px; color: var(--muted); font-size: 0.7rem; font-style: italic; font-variant-numeric: tabular-nums; } diff --git a/app/templates/_jobs_table.html b/app/templates/_jobs_table.html index 5c5f5bc..0039dd3 100644 --- a/app/templates/_jobs_table.html +++ b/app/templates/_jobs_table.html @@ -35,7 +35,7 @@ {% else %}{% endif %} - {% if j.has_thumbnail %}{% else %}{% endif %} + {% if j.has_thumbnail %}{% else %}{% endif %} #{{ j.id }} {{ j.auv }} · {{ j.gp_label }} @@ -45,8 +45,11 @@ {% if j.trimmed_total %}−{{ j.trimmed_total }}{% else %}—{% endif %} {% if j.status in ('extracting','running') %} - - {{ j.progress }}% +
+ + {{ j.progress }}% +
+ {% if j.step %}
{{ j.step }}
{% endif %} {% elif j.status == 'done' and j.viser_url %} viser ↗ {% elif j.status == 'skipped' %} diff --git a/scripts/__pycache__/dispatcher.cpython-311.pyc b/scripts/__pycache__/dispatcher.cpython-311.pyc index dda077bd080a567df3dafa67071ef7ad16e7b077..88ee68dcc3ddfbd67fd7964d5aef409c9196e58c 100644 GIT binary patch delta 9291 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 None: + """Scp the latest extracted frame back to the dashboard host. Silent on failure.""" + thumb_dir = DB_PATH.parent / "thumbnails" + thumb_dir.mkdir(exist_ok=True) + thumb_local = thumb_dir / f"job_{job_id}.jpg" + rc, out, _ = ssh(worker["ssh_alias"], f"ls -1 {shlex.quote(frames_dir)}/frame_*.jpg 2>/dev/null | tail -1") + latest = out.strip() + if not latest: + return + subprocess.run( + ["scp", "-o", "BatchMode=yes", f"{worker['ssh_alias']}:{latest}", str(thumb_local)], + capture_output=True, timeout=15, + ) + + def trim_above_water_prefix(worker: dict, frames_dir: str) -> tuple[int, int, int]: """Delete leading and trailing out-of-water frames. Returns (head, tail, remaining).""" script_remote = f"/tmp/cosma-trim-{os.getpid()}.py" @@ -315,6 +332,7 @@ def do_extract(job: sqlite3.Row, worker: dict) -> str: idx = 0 total_frames_est = 0 # will be computed after each scp total_duration_s = 0.0 + n_videos = len(videos) for v in videos: vf = f"fps={FPS},scale={IMG_W}:{IMG_H}" pattern = f"{frames_dir}/frame_%06d.jpg" @@ -322,6 +340,7 @@ def do_extract(job: sqlite3.Row, worker: dict) -> str: rc_check = ssh(worker["ssh_alias"], f"test -f {shlex.quote(worker_src)}")[0] if rc_check != 0: print(f" scp {_path_basename(v)} → {worker['host']}...") + set_status(job["id"], step=f"scp {idx // 1 + 1}/{n_videos}: {_path_basename(v)}") scp_to_worker(v, worker, worker_src) dur = video_duration_s(worker, worker_src) total_duration_s += dur @@ -337,13 +356,23 @@ def do_extract(job: sqlite3.Row, worker: dict) -> str: ) ssh(worker["ssh_alias"], f"setsid bash -c {shlex.quote(bg)} >/dev/null 2>&1 &") + # 1-based index for humans. We cannot compute it from `idx` directly because idx is + # the running frame counter, so count the loop iterations via total_duration_s order. + vid_num = videos.index(v) + 1 + thumb_refresh_counter = 0 while True: # Use -s (file exists AND size > 0) to avoid race: setsid bash writes the exit code # AFTER ffmpeg finishes; a plain -f can match a zero-byte placeholder mid-write. rc_done, _, _ = ssh(worker["ssh_alias"], f"test -s {shlex.quote(exit_file)}") current = count_frames(worker, frames_dir) pct = min(99, current * 100 // total_frames_est) - set_status(job["id"], frame_count=current, progress=pct) + set_status(job["id"], frame_count=current, progress=pct, + step=f"ffmpeg {vid_num}/{n_videos}: {current} frames") + # Refresh the preview thumbnail every few polls so the dashboard reflects what the + # camera is seeing right now, not the very first (surface) frame. + thumb_refresh_counter += 1 + if thumb_refresh_counter % 3 == 1 and current > 0: + _refresh_thumbnail(worker, frames_dir, job["id"]) if rc_done == 0: break time.sleep(5) @@ -360,7 +389,7 @@ def do_extract(job: sqlite3.Row, worker: dict) -> str: set_status(job["id"], frame_count=idx, progress=min(99, idx * 100 // total_frames_est)) # Persist the measured video duration so the dashboard shows real length (segment_label # from ingest is only the timestamp of the first MP4 and lies when a segment spans multiple). - set_status(job["id"], video_duration_s=total_duration_s) + set_status(job["id"], video_duration_s=total_duration_s, step="trimming hors-eau") # Skip segments that are too short to contain a meaningful dive. min_video_s = int(os.environ.get("COSMA_QC_MIN_VIDEO_S", "480")) # 8 min default if total_duration_s < min_video_s: @@ -379,25 +408,8 @@ def do_extract(job: sqlite3.Row, worker: dict) -> str: print(f" ↳ job #{job['id']}: only {remaining} underwater frames (<{min_frames}) — marking skipped") set_status(job["id"], status="skipped", error=f"too short: {remaining} underwater frames") raise RuntimeError("skipped_short") - # Copy the first kept frame back to the dashboard host as a thumbnail so the UI can show - # what the job actually sees. Mid-point would be better but first is cheap and on disk now. - thumb_dir = DB_PATH.parent / "thumbnails" - thumb_dir.mkdir(exist_ok=True) - thumb_local = thumb_dir / f"job_{job['id']}.jpg" - subprocess.run( - ["scp", "-o", "BatchMode=yes", - f"{worker['ssh_alias']}:{frames_dir}/frame_*.jpg", str(thumb_local)], - capture_output=True, timeout=30, - ) # globbing only picks 1 on the remote side via shell expansion - # If glob scp is empty, try first explicit by listing - if not thumb_local.exists() or thumb_local.stat().st_size == 0: - rc, out, _ = ssh(worker["ssh_alias"], f"ls {shlex.quote(frames_dir)}/frame_*.jpg 2>/dev/null | head -1") - first = out.strip() - if first: - subprocess.run( - ["scp", "-o", "BatchMode=yes", f"{worker['ssh_alias']}:{first}", str(thumb_local)], - capture_output=True, timeout=30, - ) + # Snapshot the latest post-trim frame so the dashboard preview matches what the demo.py will see. + _refresh_thumbnail(worker, frames_dir, job["id"]) # Trim once per job so LVM thin pool on the host actually reclaims the freed blocks. ssh(worker["ssh_alias"], "sudo fstrim / 2>/dev/null || fstrim / 2>/dev/null", timeout=60) return frames_dir @@ -449,6 +461,7 @@ def do_reconstruct(job: sqlite3.Row, worker: dict, frames_dir: str) -> tuple[str f"done; " f"pkill -KILL -f \"demo.py.*{frames_dir}\" 2>/dev/null; exit 124" ) + set_status(job["id"], step=f"reconstruct demo.py (windowed w{window_size}, stride {stride})") rc, _, err = ssh(worker["ssh_alias"], cmd, timeout=3 * 3600) # Accept rc==0 OR PLY file exists with non-zero size (kill -TERM may return non-zero) ply_rc, ply_size, _ = ssh(worker["ssh_alias"], f"stat -c %s {shlex.quote(ply_path)} 2>/dev/null || echo 0")