dashboard preview thumbnail par job
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:
<img class=thumb src=jobs/N/thumbnail> si has_thumbnail. 48x27 px,
object-fit cover.
Backfill manuel des jobs deja done (9, 12, 13, 16, 19) via scp direct.
This commit is contained in:
11
app/main.py
11
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["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["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["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.
|
# Only expose a native viser link when port is listening. Probed on render via TCP check.
|
||||||
d["native_viser_url"] = None # filled below
|
d["native_viser_url"] = None # filled below
|
||||||
by_acq.setdefault(j["acquisition_id"], []).append(d)
|
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}"}
|
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")
|
@app.post("/jobs/{job_id}/live")
|
||||||
async def live_job(job_id: int):
|
async def live_job(job_id: int):
|
||||||
"""Return the URL of demo.py's native viser (PointCloudViewer with camera frustums,
|
"""Return the URL of demo.py's native viser (PointCloudViewer with camera frustums,
|
||||||
|
|||||||
@@ -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 .viser-link:hover { background: var(--accent, #4a9); color: white; }
|
||||||
.job-item.skipped { opacity: 0.55; }
|
.job-item.skipped { opacity: 0.55; }
|
||||||
.job-item.skipped .label { font-style: italic; }
|
.job-item.skipped .label { font-style: italic; }
|
||||||
|
|
||||||
|
.job-item .thumb { width: 48px; height: 27px; object-fit: cover; border-radius: 3px; margin-right: 4px; }
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
{% else %}<span class="sq">■</span>{% endif %}
|
{% else %}<span class="sq">■</span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="label">
|
<span class="label">
|
||||||
|
{% if j.has_thumbnail %}<img class="thumb" src="jobs/{{ j.id }}/thumbnail" alt="" loading="lazy">{% endif %}
|
||||||
<span class="job-id">#{{ j.id }}</span>
|
<span class="job-id">#{{ j.id }}</span>
|
||||||
<span class="auv-gp" title="{{ j.gopro_serial }}">{{ j.auv }} {{ j.gp_label }}</span>
|
<span class="auv-gp" title="{{ j.gopro_serial }}">{{ j.auv }} {{ j.gp_label }}</span>
|
||||||
<span class="seg">{{ j.segment_label }}</span>
|
<span class="seg">{{ j.segment_label }}</span>
|
||||||
|
|||||||
Binary file not shown.
@@ -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")
|
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")
|
set_status(job["id"], status="skipped", error=f"too short: {remaining} underwater frames")
|
||||||
raise RuntimeError("skipped_short")
|
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.
|
# 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)
|
ssh(worker["ssh_alias"], "sudo fstrim / 2>/dev/null || fstrim / 2>/dev/null", timeout=60)
|
||||||
return frames_dir
|
return frames_dir
|
||||||
|
|||||||
Reference in New Issue
Block a user