diff --git a/app/main.py b/app/main.py index 152ef43..55640e7 100644 --- a/app/main.py +++ b/app/main.py @@ -167,6 +167,7 @@ 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() # Only expose a native viser link when port is listening. Probed on render via TCP check. d["native_viser_url"] = None # filled below by_acq.setdefault(j["acquisition_id"], []).append(d) @@ -392,6 +393,16 @@ async def view_job(job_id: int): return {"url": f"http://{worker['host']}:{port}"} +@app.get("/jobs/{job_id}/thumbnail") +async def job_thumbnail(job_id: int): + """Serve the cached thumbnail the dispatcher scp'd after trimming out-of-water frames.""" + from fastapi.responses import FileResponse + thumb = DB_PATH.parent / "thumbnails" / f"job_{job_id}.jpg" + if not thumb.exists(): + raise HTTPException(404, "no thumbnail yet") + return FileResponse(thumb, media_type="image/jpeg") + + @app.post("/jobs/{job_id}/live") async def live_job(job_id: int): """Return the URL of demo.py's native viser (PointCloudViewer with camera frustums, diff --git a/app/static/style.css b/app/static/style.css index f43ac2a..46c4163 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -127,3 +127,5 @@ code { background: rgba(255,255,255,0.05); padding: 0 0.25rem; border-radius: 3p .job-item .viser-link:hover { background: var(--accent, #4a9); color: white; } .job-item.skipped { opacity: 0.55; } .job-item.skipped .label { font-style: italic; } + +.job-item .thumb { width: 48px; height: 27px; object-fit: cover; border-radius: 3px; margin-right: 4px; } diff --git a/app/templates/_jobs_table.html b/app/templates/_jobs_table.html index 648c369..45a7925 100644 --- a/app/templates/_jobs_table.html +++ b/app/templates/_jobs_table.html @@ -18,6 +18,7 @@ {% else %}{% endif %} + {% if j.has_thumbnail %}{% endif %} #{{ j.id }} {{ j.auv }} {{ j.gp_label }} {{ j.segment_label }} diff --git a/scripts/__pycache__/dispatcher.cpython-311.pyc b/scripts/__pycache__/dispatcher.cpython-311.pyc index 3ea3824..dda077b 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 4e4e730..41d45ce 100644 --- a/scripts/dispatcher.py +++ b/scripts/dispatcher.py @@ -379,6 +379,25 @@ 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, + ) # 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