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 dda077b..88ee68d 100644 Binary files a/scripts/__pycache__/dispatcher.cpython-311.pyc and b/scripts/__pycache__/dispatcher.cpython-311.pyc differ diff --git a/scripts/dispatcher.py b/scripts/dispatcher.py index 41d45ce..c2bb537 100644 --- a/scripts/dispatcher.py +++ b/scripts/dispatcher.py @@ -79,6 +79,8 @@ def _migrate(): ("trimmed_head", "INTEGER DEFAULT 0"), ("trimmed_tail", "INTEGER DEFAULT 0"), ("video_duration_s", "REAL DEFAULT 0"), + # Human-readable phase so the dashboard can show "scp 2/3", "ffmpeg 45%", "reconstruct 12/113 windows"... + ("step", "TEXT"), ): if col not in cols: conn.execute(f"ALTER TABLE jobs ADD COLUMN {col} {ddl}") @@ -236,6 +238,21 @@ print(f'TRIM_RESULT {removed_head} {removed_tail} {end - start}') """ +def _refresh_thumbnail(worker: dict, frames_dir: str, job_id: int) -> 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")