diff --git a/app/static/style.css b/app/static/style.css index 8f02c34..08c8539 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -71,7 +71,10 @@ progress::-moz-progress-bar { background: var(--accent); } .job-list .job-item.error .icon { background: var(--err); color: #fff; font-weight: bold; } .job-list .label { color: var(--text); overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; } + white-space: nowrap; display: flex; flex-direction: column; gap: 2px; } +.prog-wrap { display: flex; align-items: center; gap: 4px; height: 10px; position: relative; width: 100%; max-width: 160px; } +.prog-fill { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.5s; min-width: 2px; } +.prog-text { font-size: 0.7rem; color: var(--muted); white-space: nowrap; } .job-list .dur { color: var(--muted); font-size: 0.78rem; } .job-list .ext { margin-left: 0.5rem; color: var(--accent); font-size: 0.75rem; } button.ext.viewer-btn { background: transparent; border: 1px solid var(--accent); diff --git a/app/templates/_jobs_table.html b/app/templates/_jobs_table.html index 2525ce5..1c748ce 100644 --- a/app/templates/_jobs_table.html +++ b/app/templates/_jobs_table.html @@ -19,6 +19,9 @@ {{ j.auv }}/{{ j.gopro_serial }}/{{ j.segment_label }} + {% if j.status in ('extracting','running') %} + {{ j.progress }}% + {% endif %} {% if j.status == 'done' and j.ply_path %} {% endif %} diff --git a/scripts/dispatcher.py b/scripts/dispatcher.py index d9ced53..0770513 100644 --- a/scripts/dispatcher.py +++ b/scripts/dispatcher.py @@ -153,30 +153,59 @@ def _path_basename(p: str) -> str: return Path(p).name +def video_duration_s(worker: dict, worker_src: str) -> float: + _, out, _ = ssh(worker["ssh_alias"], + f"ffprobe -v error -show_entries format=duration " + f"-of csv=p=0 {shlex.quote(worker_src)} 2>/dev/null || echo 0") + try: + return float(out.strip()) + except Exception: + return 0.0 + + def do_extract(job: sqlite3.Row, worker: dict) -> str: videos = json.loads(job["video_paths"]) frames_dir = f"{worker['frames_dir']}/job_{job['id']}" ssh(worker["ssh_alias"], f"mkdir -p {shlex.quote(frames_dir)}") idx = 0 + total_frames_est = 0 # will be computed after each scp for v in videos: vf = f"fps={FPS},scale={IMG_W}:{IMG_H}" pattern = f"{frames_dir}/frame_%06d.jpg" - # Copy video to worker if it doesn't exist there worker_src = f"{frames_dir}/src_{_path_basename(v)}" 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']}...") scp_to_worker(v, worker, worker_src) - cmd = ( + dur = video_duration_s(worker, worker_src) + total_frames_est += max(1, int(dur * FPS)) + + exit_file = f"/tmp/cosma-ffmpeg-{job['id']}-{idx}.exit" + bg = ( + f"rm -f {shlex.quote(exit_file)}; " f"ffmpeg -hide_banner -loglevel error -i {shlex.quote(worker_src)} " - f"-vf {shlex.quote(vf)} -start_number {idx} -q:v 4 " - f"{shlex.quote(pattern)}" + f"-vf {shlex.quote(vf)} -start_number {idx} -q:v 4 {shlex.quote(pattern)} " + f"/tmp/cosma-ffmpeg-{job['id']}.log 2>&1; " + f"echo $? > {shlex.quote(exit_file)}" ) - rc, _, err = ssh(worker["ssh_alias"], cmd, timeout=3600) + ssh(worker["ssh_alias"], f"setsid bash -c {shlex.quote(bg)} >/dev/null 2>&1 &") + + while True: + rc_done, _, _ = ssh(worker["ssh_alias"], f"test -f {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) + if rc_done == 0: + break + time.sleep(5) + + _, code_str, _ = ssh(worker["ssh_alias"], f"cat {shlex.quote(exit_file)} 2>/dev/null || echo 1") + rc = int(code_str.strip()) if code_str.strip().isdigit() else 1 if rc != 0: + _, err, _ = ssh(worker["ssh_alias"], f"cat /tmp/cosma-ffmpeg-{job['id']}.log 2>/dev/null | tail -5 || echo ''") raise RuntimeError(f"ffmpeg failed on {v}: {err[:200]}") idx = count_frames(worker, frames_dir) - set_status(job["id"], frame_count=idx) + set_status(job["id"], frame_count=idx, progress=min(99, idx * 100 // total_frames_est)) return frames_dir