浏览代码

fix(client): reconstruction is a durable server-side job; the client is just a view

Root cause of 'it stopped': the phone lost its polling connection mid-COLMAP, but
the server-side worker kept going and the job actually finished (web-optimized glb
and all). The client just had no way to find its job again on reconnect.

- Persist the active job to the URL (?job=) AND localStorage, so a reload / lost
  signal / reopening the app hours later resumes the SAME job (progress or scene).
- Auto-resume on load from URL or localStorage.
- Polling shrugs off transient fetch failures (never freezes); 404 → back to upload.
- Re-sync on visibilitychange / online / focus (mobile freezes backgrounded timers).
- Bump SW cache v2→v3 so clients pick up the new shell.
Oivo Agent 1 周之前
父节点
当前提交
d731854369
共有 2 个文件被更改,包括 57 次插入16 次删除
  1. 56 15
      frontend/app.js
  2. 1 1
      frontend/sw.js

+ 56 - 15
frontend/app.js

@@ -28,9 +28,41 @@ function updateStages(current, status) {
   });
 }
 
+// The reconstruction runs entirely server-side (a background worker persists
+// each job to disk). The client is JUST A VIEW: it remembers the active job id
+// (URL + localStorage) so a reload / reconnect / re-open resumes the SAME job,
+// and its polling shrugs off transient network drops instead of freezing.
+const LAST_JOB_KEY = 'genrecon:lastJob';
+let currentJob = null;
+
+function rememberJob(id) {
+  currentJob = id;
+  try { localStorage.setItem(LAST_JOB_KEY, id); } catch { /* private mode */ }
+  const u = new URL(location.href); u.searchParams.set('job', id); history.replaceState(null, '', u);
+}
+function forgetJob() {
+  currentJob = null;
+  try { localStorage.removeItem(LAST_JOB_KEY); } catch { /* private mode */ }
+  const u = new URL(location.href); u.searchParams.delete('job'); history.replaceState(null, '', u);
+}
+function stopPolling() { if (poll) { clearInterval(poll); poll = null; } }
+function startPolling(id) {
+  rememberJob(id);
+  stopPolling();
+  buildStages(); show('view-progress');
+  poll = setInterval(() => refresh(id), 2000);
+  refresh(id);
+}
+
 async function refresh(id) {
   let st;
-  try { st = await (await fetch(`/api/jobs/${id}`)).json(); } catch { return; }
+  try {
+    const r = await fetch(`/api/jobs/${id}`);
+    if (r.status === 404) {          // job unknown/expired → back to upload
+      stopPolling(); forgetJob(); show('view-upload'); return;
+    }
+    st = await r.json();
+  } catch { return; }                // transient blip (reconnect etc.) → retry next tick, never freeze
   $('bar-fill').style.width = (st.progress || 0) + '%';
   $('progress-msg').textContent = st.message || st.status || '';
   $('progress-msg').classList.toggle('error', st.status === 'error');
@@ -41,10 +73,10 @@ async function refresh(id) {
   } catch { /* no log yet */ }
 
   if (st.status === 'done') {
-    clearInterval(poll); poll = null;
+    stopPolling();
     openViewer(`/api/jobs/${id}/scene.glb`);
   } else if (st.status === 'error') {
-    clearInterval(poll); poll = null;
+    stopPolling();
     $('progress-msg').textContent = '⚠ ' + (st.error || 'błąd rekonstrukcji');
   }
 }
@@ -59,8 +91,8 @@ async function upload(file) {
   try { res = await (await fetch('/api/jobs', { method: 'POST', body: fd })).json(); }
   catch (e) { $('progress-msg').classList.add('error'); $('progress-msg').textContent = 'Nie udało się wysłać: ' + e; return; }
   if (res.error) { $('progress-msg').classList.add('error'); $('progress-msg').textContent = res.error; return; }
-  poll = setInterval(() => refresh(res.id), 1500);
-  refresh(res.id);
+  // Upload done → hand off to the durable server-side job; the client is now just a view.
+  startPolling(res.id);
 }
 
 function openViewer(url) {
@@ -73,7 +105,8 @@ function openViewer(url) {
 }
 
 function backToUpload() {
-  if (poll) { clearInterval(poll); poll = null; }
+  stopPolling();
+  forgetJob();
   stopViewer();
   show('view-upload');
 }
@@ -112,15 +145,23 @@ function init() {
   wireJoystick();
   if ('serviceWorker' in navigator) navigator.serviceWorker.register('./sw.js').catch(() => {});
 
-  // Deep-link: /?job=<id> resumes (or opens) a specific reconstruction, so a
-  // finished scene has a shareable URL and progress survives a reload.
-  const jobId = new URLSearchParams(location.search).get('job');
-  if (jobId) {
-    buildStages();
-    show('view-progress');
-    poll = setInterval(() => refresh(jobId), 1500);
-    refresh(jobId);
-  }
+  // Resume the active job on load — from the URL (?job=, shareable) or, failing
+  // that, from localStorage. So closing the tab, losing signal, or reopening the
+  // app hours later drops you right back onto the still-running / finished scan.
+  let resumeId = new URLSearchParams(location.search).get('job');
+  if (!resumeId) { try { resumeId = localStorage.getItem(LAST_JOB_KEY); } catch { /* private mode */ } }
+  if (resumeId) startPolling(resumeId);
+
+  // Mobile browsers freeze timers on a backgrounded tab; re-sync the moment the
+  // tab is visible / the network is back, instead of waiting for the next tick.
+  const resync = () => {
+    if (!currentJob || document.hidden) return;
+    if ($('view-viewer').classList.contains('active')) return;  // don't reload the walkthrough mid-explore
+    refresh(currentJob);
+  };
+  document.addEventListener('visibilitychange', resync);
+  window.addEventListener('online', resync);
+  window.addEventListener('focus', resync);
 }
 
 init();

+ 1 - 1
frontend/sw.js

@@ -1,5 +1,5 @@
 // Offline app-shell cache. API calls are never cached.
-const CACHE = 'genrecon-v2';   // bump on shell change (app.js/viewer.js) so clients refresh
+const CACHE = 'genrecon-v3';   // bump on shell change (app.js/viewer.js) so clients refresh
 const SHELL = [
   './', './index.html', './styles.css', './app.js', './viewer.js', './manifest.webmanifest',
   './demo.glb', './icons/icon-192.png', './icons/icon-512.png',