From 960ebc03939c2ffd7b19ea3b20d39feadbe3510f Mon Sep 17 00:00:00 2001 From: Flag Date: Wed, 22 Apr 2026 22:06:43 +0000 Subject: [PATCH] =?UTF-8?q?dashboard=20=C2=97=20preview=20thumbnail=20par?= =?UTF-8?q?=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatcher scp frame_*.jpg (premiere apres trim head) vers /var/lib/cosma-qc/thumbnails/job_N.jpg a la fin de do_extract. Endpoint GET /jobs/{id}/thumbnail serve via FileResponse. Template: si has_thumbnail. 48x27 px, object-fit cover. Backfill manuel des jobs deja done (9, 12, 13, 16, 19) via scp direct. --- app/main.py | 11 ++++++++++ app/static/style.css | 2 ++ app/templates/_jobs_table.html | 1 + .../__pycache__/dispatcher.cpython-311.pyc | Bin 43751 -> 45287 bytes scripts/dispatcher.py | 19 ++++++++++++++++++ 5 files changed, 33 insertions(+) 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 3ea38245c37d5cecefe2c2c26a1dc65ea94bff68..dda077bd080a567df3dafa67071ef7ad16e7b077 100644 GIT binary patch delta 6330 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 bk8$MXHj?sogS*uwU2~ 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