app.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import { startViewer, stopViewer, recenter, setJoystick } from './viewer.js';
  2. const STAGES = [
  3. ['frames', 'Klatki z wideo'],
  4. ['colmap', 'Pozycje kamer (COLMAP)'],
  5. ['reconstruct', 'Rekonstrukcja 3D (GenRecon)'],
  6. ['glb', 'Składanie sceny (GLB)'],
  7. ];
  8. const $ = (id) => document.getElementById(id);
  9. let poll = null;
  10. function show(view) {
  11. for (const v of document.querySelectorAll('.view')) v.classList.remove('active');
  12. $(view).classList.add('active');
  13. }
  14. function buildStages() {
  15. $('stages').innerHTML = STAGES
  16. .map(([k, label]) => `<li data-stage="${k}"><span class="dot"></span>${label}</li>`).join('');
  17. }
  18. function updateStages(current, status) {
  19. const order = STAGES.map((s) => s[0]);
  20. const idx = order.indexOf(current);
  21. document.querySelectorAll('#stages li').forEach((li, i) => {
  22. li.classList.toggle('done', status === 'done' || i < idx);
  23. li.classList.toggle('active', status !== 'done' && i === idx);
  24. });
  25. }
  26. // The reconstruction runs entirely server-side (a background worker persists
  27. // each job to disk). The client is JUST A VIEW: it remembers the active job id
  28. // (URL + localStorage) so a reload / reconnect / re-open resumes the SAME job,
  29. // and its polling shrugs off transient network drops instead of freezing.
  30. const LAST_JOB_KEY = 'genrecon:lastJob';
  31. let currentJob = null;
  32. function rememberJob(id) {
  33. currentJob = id;
  34. try { localStorage.setItem(LAST_JOB_KEY, id); } catch { /* private mode */ }
  35. const u = new URL(location.href); u.searchParams.set('job', id); history.replaceState(null, '', u);
  36. }
  37. function forgetJob() {
  38. currentJob = null;
  39. try { localStorage.removeItem(LAST_JOB_KEY); } catch { /* private mode */ }
  40. const u = new URL(location.href); u.searchParams.delete('job'); history.replaceState(null, '', u);
  41. }
  42. function stopPolling() { if (poll) { clearInterval(poll); poll = null; } }
  43. function startPolling(id) {
  44. rememberJob(id);
  45. stopPolling();
  46. buildStages(); show('view-progress');
  47. poll = setInterval(() => refresh(id), 2000);
  48. refresh(id);
  49. }
  50. async function refresh(id) {
  51. let st;
  52. try {
  53. const r = await fetch(`/api/jobs/${id}`);
  54. if (r.status === 404) { // job unknown/expired → back to upload
  55. stopPolling(); forgetJob(); show('view-upload'); return;
  56. }
  57. st = await r.json();
  58. } catch { return; } // transient blip (reconnect etc.) → retry next tick, never freeze
  59. $('bar-fill').style.width = (st.progress || 0) + '%';
  60. $('progress-msg').textContent = st.message || st.status || '';
  61. $('progress-msg').classList.toggle('error', st.status === 'error');
  62. updateStages(st.stage, st.status);
  63. try {
  64. const log = await (await fetch(`/api/jobs/${id}/log`)).text();
  65. const el = $('log'); el.textContent = log; el.scrollTop = el.scrollHeight;
  66. } catch { /* no log yet */ }
  67. if (st.status === 'done') {
  68. stopPolling();
  69. openViewer(`/api/jobs/${id}/scene.glb`);
  70. } else if (st.status === 'error') {
  71. stopPolling();
  72. $('progress-msg').textContent = '⚠ ' + (st.error || 'błąd rekonstrukcji');
  73. }
  74. }
  75. async function upload(file) {
  76. if (!file) return;
  77. buildStages();
  78. $('bar-fill').style.width = '0'; $('progress-msg').textContent = 'Wysyłanie filmu…'; $('log').textContent = '';
  79. show('view-progress');
  80. const fd = new FormData(); fd.append('video', file);
  81. let res;
  82. try { res = await (await fetch('/api/jobs', { method: 'POST', body: fd })).json(); }
  83. catch (e) { $('progress-msg').classList.add('error'); $('progress-msg').textContent = 'Nie udało się wysłać: ' + e; return; }
  84. if (res.error) { $('progress-msg').classList.add('error'); $('progress-msg').textContent = res.error; return; }
  85. // Upload done → hand off to the durable server-side job; the client is now just a view.
  86. startPolling(res.id);
  87. }
  88. function openViewer(url) {
  89. show('view-viewer');
  90. document.body.classList.toggle('touch', matchMedia('(pointer: coarse)').matches);
  91. startViewer(url).catch((e) => {
  92. show('view-upload');
  93. alert('Nie udało się załadować sceny 3D: ' + e);
  94. });
  95. }
  96. function backToUpload() {
  97. stopPolling();
  98. forgetJob();
  99. stopViewer();
  100. show('view-upload');
  101. }
  102. // --- mobile joystick ---
  103. function wireJoystick() {
  104. const stick = $('joystick'), knob = $('joystick-knob');
  105. let active = null, cx = 0, cy = 0, R = 45;
  106. const set = (dx, dy) => {
  107. const len = Math.hypot(dx, dy) || 1, cl = Math.min(len, R);
  108. dx = dx / len * cl; dy = dy / len * cl;
  109. knob.style.transform = `translate(${dx}px,${dy}px)`;
  110. setJoystick(dx / R, -dy / R); // up = forward
  111. };
  112. stick.addEventListener('pointerdown', (e) => {
  113. active = e.pointerId; const r = stick.getBoundingClientRect();
  114. cx = r.left + r.width / 2; cy = r.top + r.height / 2;
  115. stick.setPointerCapture(e.pointerId); set(e.clientX - cx, e.clientY - cy);
  116. });
  117. stick.addEventListener('pointermove', (e) => { if (e.pointerId === active) set(e.clientX - cx, e.clientY - cy); });
  118. const end = (e) => { if (e.pointerId === active) { active = null; knob.style.transform = ''; setJoystick(0, 0); } };
  119. stick.addEventListener('pointerup', end);
  120. stick.addEventListener('pointercancel', end);
  121. }
  122. function init() {
  123. const drop = $('drop'), input = $('file-input');
  124. input.addEventListener('change', () => upload(input.files[0]));
  125. ['dragenter', 'dragover'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('drag'); }));
  126. ['dragleave', 'drop'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('drag'); }));
  127. drop.addEventListener('drop', (e) => upload(e.dataTransfer.files[0]));
  128. $('btn-demo').addEventListener('click', () => openViewer('./demo.glb'));
  129. $('btn-cancel').addEventListener('click', backToUpload);
  130. $('btn-new').addEventListener('click', backToUpload);
  131. $('btn-recenter').addEventListener('click', recenter);
  132. wireJoystick();
  133. if ('serviceWorker' in navigator) navigator.serviceWorker.register('./sw.js').catch(() => {});
  134. // Resume the active job on load — from the URL (?job=, shareable) or, failing
  135. // that, from localStorage. So closing the tab, losing signal, or reopening the
  136. // app hours later drops you right back onto the still-running / finished scan.
  137. let resumeId = new URLSearchParams(location.search).get('job');
  138. if (!resumeId) { try { resumeId = localStorage.getItem(LAST_JOB_KEY); } catch { /* private mode */ } }
  139. if (resumeId) startPolling(resumeId);
  140. // Mobile browsers freeze timers on a backgrounded tab; re-sync the moment the
  141. // tab is visible / the network is back, instead of waiting for the next tick.
  142. const resync = () => {
  143. if (!currentJob || document.hidden) return;
  144. if ($('view-viewer').classList.contains('active')) return; // don't reload the walkthrough mid-explore
  145. refresh(currentJob);
  146. };
  147. document.addEventListener('visibilitychange', resync);
  148. window.addEventListener('online', resync);
  149. window.addEventListener('focus', resync);
  150. }
  151. init();