/* * app.js * UI wiring for the Lunar Impact Flash Detector. * Handles: file loading, video playback, frame-stepped analysis, progress, * overlay rendering (Moon disc + markers + live highlight), results, CSV export. */ (function () { "use strict"; // ---- DOM refs ---- const dropZone = document.getElementById("dropZone"); const fileInput = document.getElementById("fileInput"); const stage = document.getElementById("stage"); const video = document.getElementById("video"); const overlay = document.getElementById("overlay"); const octx = overlay.getContext("2d"); const playBtn = document.getElementById("playBtn"); const prevFrameBtn = document.getElementById("prevFrameBtn"); const nextFrameBtn = document.getElementById("nextFrameBtn"); const seek = document.getElementById("seek"); const timeLabel = document.getElementById("timeLabel"); const frameLabel = document.getElementById("frameLabel"); const newFileBtn = document.getElementById("newFileBtn"); const viewport = document.getElementById("viewport"); const videoWrap = document.getElementById("videoWrap"); const zoomInBtn = document.getElementById("zoomInBtn"); const zoomOutBtn = document.getElementById("zoomOutBtn"); const zoomResetBtn = document.getElementById("zoomResetBtn"); const zoomLabel = document.getElementById("zoomLabel"); const analyzeBtn = document.getElementById("analyzeBtn"); const progressWrap = document.getElementById("progressWrap"); const progressBar = document.getElementById("progressBar"); const progressText = document.getElementById("progressText"); const cancelBtn = document.getElementById("cancelBtn"); const sensitivity = document.getElementById("sensitivity"); const sensVal = document.getElementById("sensVal"); const minSize = document.getElementById("minSize"); const minSizeVal = document.getElementById("minSizeVal"); const maxSize = document.getElementById("maxSize"); const maxSizeVal = document.getElementById("maxSizeVal"); const minDur = document.getElementById("minDur"); const minDurVal = document.getElementById("minDurVal"); const maxDur = document.getElementById("maxDur"); const maxDurVal = document.getElementById("maxDurVal"); const highlightPlayback = document.getElementById("highlightPlayback"); const showMarkers = document.getElementById("showMarkers"); const flashCount = document.getElementById("flashCount"); const exportBtn = document.getElementById("exportBtn"); const emptyState = document.getElementById("emptyState"); const resultsTable = document.getElementById("resultsTable"); const resultsBody = document.getElementById("resultsBody"); const toast = document.getElementById("toast"); // ---- state ---- let currentURL = null; let flashes = []; let analyzing = false; let cancelRequested = false; let activeRow = null; let analysisGeom = { w: 0, h: 0 }; let detectedFps = 25; const SENS_LABELS = { 1: "Very low", 2: "Low", 3: "Medium", 4: "High", 5: "Very high" }; const sampleCanvas = document.createElement("canvas"); const sctx = sampleCanvas.getContext("2d", { willReadFrequently: true }); // --------------------------------------------------------------------------- // File handling // --------------------------------------------------------------------------- function openFile(file) { if (!file) return; if (!file.type.startsWith("video/") && !/\.(mp4|mov|m4v|webm|avi)$/i.test(file.name)) { showToast("Please choose a video file (MP4 recommended)."); return; } if (currentURL) URL.revokeObjectURL(currentURL); currentURL = URL.createObjectURL(file); showVideo(currentURL); showToast("Loaded " + file.name); } function showVideo(src) { resetResults(); // Everything is loaded from same-origin blob: URLs (user uploads and the // fetched sample alike), so the canvas is never tainted and no crossOrigin // attribute is needed. Clear any stale attribute defensively. video.removeAttribute("crossorigin"); video.src = src; video.load(); dropZone.classList.add("hidden"); stage.classList.remove("hidden"); resetZoom(); } dropZone.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", (e) => openFile(e.target.files[0])); newFileBtn.addEventListener("click", () => fileInput.click()); ["dragenter", "dragover"].forEach((evt) => dropZone.addEventListener(evt, (e) => { e.preventDefault(); dropZone.classList.add("dragover"); }) ); ["dragleave", "drop"].forEach((evt) => dropZone.addEventListener(evt, (e) => { e.preventDefault(); dropZone.classList.remove("dragover"); }) ); dropZone.addEventListener("drop", (e) => { const f = e.dataTransfer.files && e.dataTransfer.files[0]; openFile(f); }); window.addEventListener("dragover", (e) => e.preventDefault()); window.addEventListener("drop", (e) => { if (e.target === dropZone) return; e.preventDefault(); const f = e.dataTransfer.files && e.dataTransfer.files[0]; if (f) openFile(f); }); // --------------------------------------------------------------------------- // Video / transport // --------------------------------------------------------------------------- video.addEventListener("loadedmetadata", () => { resizeOverlay(); seek.value = 0; updateTimeLabel(); }); video.addEventListener("timeupdate", () => { if (!analyzing) { seek.value = String((video.currentTime / (video.duration || 1)) * 1000); updateTimeLabel(); if (flashes.length) renderOverlay(); } }); video.addEventListener("play", () => (playBtn.innerHTML = "❙❙")); video.addEventListener("pause", () => (playBtn.innerHTML = "►")); video.addEventListener("ended", () => (playBtn.innerHTML = "►")); video.addEventListener("seeked", updateTimeLabel); playBtn.addEventListener("click", () => { if (analyzing) return; if (video.paused) video.play(); else video.pause(); }); seek.addEventListener("input", () => { if (analyzing) return; const t = (Number(seek.value) / 1000) * (video.duration || 0); video.currentTime = t; updateTimeLabel(); }); // Frame stepping. Browsers don't expose exact frame times, so we step by the // detected/estimated frame duration. Pauses playback so each step is visible. function frameDuration() { const fps = detectedFps && detectedFps > 0 ? detectedFps : 30; return 1 / fps; } function stepFrame(dir) { if (analyzing || !video.duration) return; video.pause(); const fd = frameDuration(); let t = video.currentTime + dir * fd; t = Math.max(0, Math.min(t, video.duration - 1e-3)); video.currentTime = t; // updateTimeLabel fires on 'seeked'; overlay refresh too. setTimeout(() => { updateTimeLabel(); if (flashes.length) renderOverlay(); }, 0); } prevFrameBtn.addEventListener("click", () => stepFrame(-1)); nextFrameBtn.addEventListener("click", () => stepFrame(1)); // Arrow keys step frames (unless typing in a control). window.addEventListener("keydown", (e) => { if (stage.classList.contains("hidden")) return; const tag = (e.target && e.target.tagName) || ""; if (/INPUT|TEXTAREA|SELECT/.test(tag)) return; if (e.key === "ArrowLeft") { e.preventDefault(); stepFrame(-1); } else if (e.key === "ArrowRight") { e.preventDefault(); stepFrame(1); } }); window.addEventListener("resize", resizeOverlay); function resizeOverlay() { // Use layout size (pre-transform); the zoom transform scales the whole // video-wrap (video + overlay) together, so overlay math stays in the // unscaled coordinate space. const w = video.offsetWidth, h = video.offsetHeight; if (w && h) { overlay.width = w; overlay.height = h; } renderOverlay(); } function currentFrameNumber() { if (!video.duration) return null; return Math.round(video.currentTime * (detectedFps && detectedFps > 0 ? detectedFps : 30)); } function updateTimeLabel() { timeLabel.textContent = fmtTime(video.currentTime) + " / " + fmtTime(video.duration || 0); const fn = currentFrameNumber(); if (frameLabel) frameLabel.textContent = fn === null ? "frame \u2013" : "frame " + fn; } function fmtTime(sec) { if (!isFinite(sec)) sec = 0; const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return m + ":" + String(s).padStart(2, "0"); } // --------------------------------------------------------------------------- // Zoom & pan // --------------------------------------------------------------------------- let zoom = 1, panX = 0, panY = 0; const MIN_ZOOM = 1, MAX_ZOOM = 8; function applyTransform() { videoWrap.style.transform = "translate(" + panX + "px," + panY + "px) scale(" + zoom + ")"; zoomLabel.textContent = Math.round(zoom * 100) + "%"; viewport.classList.toggle("zoomed", zoom > 1); } // Clamp pan so you can't drag the content entirely out of view. function clampPan() { const vw = viewport.clientWidth, vh = viewport.clientHeight; const cw = vw * zoom, ch = vh * zoom; const maxX = 0, minX = vw - cw; const maxY = 0, minY = vh - ch; panX = Math.min(maxX, Math.max(minX, panX)); panY = Math.min(maxY, Math.max(minY, panY)); } // Zoom toward a point (px,py) in viewport pixels, keeping it stationary. function zoomAt(newZoom, px, py) { newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, newZoom)); if (newZoom === zoom) return; // content coord under cursor before zoom const cx = (px - panX) / zoom; const cy = (py - panY) / zoom; zoom = newZoom; // keep that content point under the cursor panX = px - cx * zoom; panY = py - cy * zoom; if (zoom === 1) { panX = 0; panY = 0; } clampPan(); applyTransform(); } function zoomBy(factor, px, py) { const r = viewport.getBoundingClientRect(); if (px == null) { px = r.width / 2; py = r.height / 2; } zoomAt(zoom * factor, px, py); } zoomInBtn.addEventListener("click", () => zoomBy(1.4)); zoomOutBtn.addEventListener("click", () => zoomBy(1 / 1.4)); zoomResetBtn.addEventListener("click", () => { zoom = 1; panX = 0; panY = 0; applyTransform(); }); viewport.addEventListener("wheel", (e) => { if (stage.classList.contains("hidden")) return; e.preventDefault(); const r = viewport.getBoundingClientRect(); const px = e.clientX - r.left, py = e.clientY - r.top; zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15, px, py); }, { passive: false }); // Drag to pan when zoomed. let dragging = false, dragStartX = 0, dragStartY = 0, panStartX = 0, panStartY = 0; viewport.addEventListener("mousedown", (e) => { if (zoom <= 1) return; dragging = true; dragStartX = e.clientX; dragStartY = e.clientY; panStartX = panX; panStartY = panY; viewport.classList.add("panning"); e.preventDefault(); }); window.addEventListener("mousemove", (e) => { if (!dragging) return; panX = panStartX + (e.clientX - dragStartX); panY = panStartY + (e.clientY - dragStartY); clampPan(); applyTransform(); }); window.addEventListener("mouseup", () => { if (!dragging) return; dragging = false; viewport.classList.remove("panning"); }); // Center the zoom on a specific analysis-space point at a given zoom level. function focusOn(analysisCx, analysisCy, targetZoom) { const { ox, oy, dw, dh } = contentRect(); // point in unscaled viewport/overlay pixels const px = ox + (analysisCx / (analysisGeom.w || 1)) * dw; const py = oy + (analysisCy / (analysisGeom.h || 1)) * dh; zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, targetZoom || 3)); // place (px,py) at viewport center panX = viewport.clientWidth / 2 - px * zoom; panY = viewport.clientHeight / 2 - py * zoom; clampPan(); applyTransform(); } function resetZoom() { zoom = 1; panX = 0; panY = 0; applyTransform(); } // --------------------------------------------------------------------------- // Settings labels // --------------------------------------------------------------------------- function refreshLabels() { sensVal.textContent = SENS_LABELS[sensitivity.value]; minSizeVal.textContent = minSize.value + " px"; maxSizeVal.textContent = maxSize.value + " px"; minDurVal.textContent = minDur.value + " ms"; maxDurVal.textContent = maxDur.value + " ms"; } [sensitivity, minSize, maxSize, minDur, maxDur].forEach((el) => el.addEventListener("input", refreshLabels) ); refreshLabels(); // --------------------------------------------------------------------------- // Collapsible sections (Detection settings, About) // --------------------------------------------------------------------------- function makeCollapsible(toggle, body) { if (!toggle || !body) return; // Reflect the markup's starting state in the inline max-height. const startExpanded = toggle.getAttribute("aria-expanded") === "true"; body.style.maxHeight = startExpanded ? "none" : "0px"; toggle.addEventListener("click", () => { const expanded = toggle.getAttribute("aria-expanded") === "true"; if (expanded) { body.style.maxHeight = body.scrollHeight + "px"; // fix current height first requestAnimationFrame(() => { body.classList.add("collapsed"); body.style.maxHeight = "0px"; }); toggle.setAttribute("aria-expanded", "false"); } else { body.classList.remove("collapsed"); body.style.maxHeight = body.scrollHeight + "px"; toggle.setAttribute("aria-expanded", "true"); } }); // After expanding, clear the fixed height so content can reflow freely. body.addEventListener("transitionend", (e) => { if (e.propertyName === "max-height" && toggle.getAttribute("aria-expanded") === "true") { body.style.maxHeight = "none"; } }); } makeCollapsible(document.getElementById("settingsToggle"), document.getElementById("settingsBody")); makeCollapsible(document.getElementById("aboutToggle"), document.getElementById("aboutBody")); // --------------------------------------------------------------------------- // Analysis // --------------------------------------------------------------------------- analyzeBtn.addEventListener("click", startAnalysis); cancelBtn.addEventListener("click", () => { cancelRequested = true; }); async function startAnalysis() { if (analyzing) return; if (!video.duration || !isFinite(video.duration)) { showToast("Video metadata not ready yet. Try again in a moment."); return; } analyzing = true; cancelRequested = false; resetResults(); video.pause(); analyzeBtn.classList.add("hidden"); progressWrap.classList.remove("hidden"); setProgress(0); progressText.textContent = "Preparing..."; try { const fps = await estimateFps(); detectedFps = fps; const frameDur = 1 / fps; const detector = new FlashDetector({ sensitivity: Number(sensitivity.value), minSize: Number(minSize.value), maxSize: Number(maxSize.value), minDurationMs: Number(minDur.value), maxDurationMs: Number(maxDur.value), }); detector.initGeometry(video.videoWidth, video.videoHeight); analysisGeom = { w: detector.analysisWidth, h: detector.analysisHeight }; sampleCanvas.width = detector.analysisWidth; sampleCanvas.height = detector.analysisHeight; const duration = video.duration; let frameIndex = 0; for (let t = 0; t < duration; t += frameDur) { if (cancelRequested) break; await seekTo(t); sctx.drawImage(video, 0, 0, sampleCanvas.width, sampleCanvas.height); const img = sctx.getImageData(0, 0, sampleCanvas.width, sampleCanvas.height); detector.processFrame(img, frameIndex, video.currentTime); frameIndex++; const frac = Math.min(0.999, t / duration); setProgress(frac); // Yield periodically so the UI stays responsive. if (frameIndex % 4 === 0) await raf(); } flashes = detector.finalize(frameDur, fps); renderResults(); setProgress(1); if (cancelRequested) showToast("Analysis cancelled. Partial results shown."); else showToast( flashes.length ? "Found " + flashes.length + " candidate flash" + (flashes.length > 1 ? "es" : "") + "." : "No candidate flashes detected." ); } catch (err) { console.error(err); showToast("Analysis error: " + (err && err.message ? err.message : err)); } finally { analyzing = false; progressWrap.classList.add("hidden"); analyzeBtn.classList.remove("hidden"); updateTimeLabel(); } } function setProgress(frac) { const pct = Math.round(frac * 100); progressBar.style.width = pct + "%"; progressText.textContent = pct + "%"; } // Seek to an exact time and wait for the frame to be decoded/presented. // Robust against "seeked never fires" by also polling readyState + a timeout. function seekTo(t) { return new Promise((resolve) => { const target = Math.min(t, Math.max(0, (video.duration || 0) - 1e-3)); let settled = false; const done = () => { if (settled) return; settled = true; video.removeEventListener("seeked", onSeeked); clearTimeout(timer); resolve(); }; const onSeeked = () => done(); video.addEventListener("seeked", onSeeked); // If the browser doesn't emit 'seeked' (same-time seek, decode stall), // fall back after a short delay so the loop can never hang. const timer = setTimeout(done, 400); // Nudge to force a distinct seek target when t == currentTime. if (Math.abs(video.currentTime - target) < 1e-4) { video.currentTime = Math.max(0, target + 1e-3); } else { video.currentTime = target; } }); } function raf() { return new Promise((r) => requestAnimationFrame(() => r())); } /* * Estimate frame rate WITHOUT calling video.play() (which was the hang * source). We step the video forward a few times using seeks and read the * media time reported by requestVideoFrameCallback if present; otherwise we * measure the smallest reliable seek granularity. Falls back to 25 fps. */ async function estimateFps() { // Preferred: requestVideoFrameCallback gives exact mediaTime per frame. if (typeof video.requestVideoFrameCallback === "function") { const fps = await estimateFpsViaVFC(); if (fps) return fps; } return 25; // typical for lunar/analog capture; safe default } function estimateFpsViaVFC() { return new Promise((resolve) => { const startAt = video.currentTime; const stamps = []; let handle = null; let finished = false; const finish = () => { if (finished) return; finished = true; try { if (handle) video.cancelVideoFrameCallback(handle); } catch (e) {} video.pause(); video.currentTime = startAt; const deltas = []; for (let i = 1; i < stamps.length; i++) { const d = stamps[i] - stamps[i - 1]; if (d > 1e-4) deltas.push(d); } deltas.sort((a, b) => a - b); let fps = 25; if (deltas.length) { const med = deltas[Math.floor(deltas.length / 2)]; if (med > 0) fps = 1 / med; } if (!isFinite(fps) || fps < 5) fps = 25; if (fps > 120) fps = 120; resolve(Math.round(fps * 100) / 100); }; const cb = (now, meta) => { stamps.push(meta.mediaTime); if (stamps.length >= 10) { finish(); return; } handle = video.requestVideoFrameCallback(cb); }; // Play briefly but guard heavily: if play() rejects or stalls, fall back. handle = video.requestVideoFrameCallback(cb); const playPromise = video.play(); if (playPromise && typeof playPromise.catch === "function") { playPromise.catch(() => finish()); } // Hard timeout so we never hang here. setTimeout(finish, 1500); }); } // --------------------------------------------------------------------------- // Results table // --------------------------------------------------------------------------- function resetResults() { flashes = []; activeRow = null; resultsBody.innerHTML = ""; resultsTable.classList.add("hidden"); emptyState.classList.remove("hidden"); emptyState.textContent = "No analysis yet. Load a video and click Analyze."; flashCount.textContent = "0"; exportBtn.disabled = true; renderOverlay(); } function renderResults() { flashCount.textContent = String(flashes.length); resultsBody.innerHTML = ""; if (!flashes.length) { resultsTable.classList.add("hidden"); emptyState.classList.remove("hidden"); emptyState.textContent = "Analysis complete — no candidate flashes found."; renderOverlay(); return; } emptyState.classList.add("hidden"); resultsTable.classList.remove("hidden"); exportBtn.disabled = false; for (const f of flashes) { const tr = document.createElement("tr"); const frameCell = f.startFrame === f.endFrame ? ("frame " + f.peakFrame) : ("frames " + f.startFrame + "\u2013" + f.endFrame + "
peak " + f.peakFrame + ""); tr.innerHTML = "" + f.index + "" + "" + fmtTime(f.peakTime) + "
" + frameCell + "" + "" + f.x + ", " + f.y + "" + "" + fmtDuration(f.durationSec, f.frames) + "" + "" + f.bboxW + "×" + f.bboxH + " px
" + f.areaSrcPx + " px²" + "" + f.peakBrightness + "
+" + f.peakDelta + "" + ""; tr.addEventListener("click", () => selectFlash(f, tr)); // Per-row "save 2-second clip" button. const clipCell = tr.lastElementChild; const clipBtn = document.createElement("button"); clipBtn.className = "btn btn-ghost btn-sm clip-btn"; clipBtn.textContent = "Save"; clipBtn.title = "Save a 2-second clip centered on this flash"; clipBtn.addEventListener("click", (e) => { e.stopPropagation(); exportClip(f, clipBtn); }); clipCell.appendChild(clipBtn); resultsBody.appendChild(tr); } renderOverlay(); } function fmtDuration(sec, frames) { const ms = Math.round(sec * 1000); return ms + " ms
" + frames + " frame" + (frames > 1 ? "s" : "") + ""; } function selectFlash(f, row) { if (activeRow) activeRow.classList.remove("active"); activeRow = row; row.classList.add("active"); video.pause(); seekTo(f.peakTime).then(() => { updateTimeLabel(); seek.value = String((f.peakTime / (video.duration || 1)) * 1000); // Zoom in and center on the flash so the user sees it up close. focusOn(f._analysis.cx, f._analysis.cy, 4); renderOverlay(f); }); } // --------------------------------------------------------------------------- // Per-flash clip export // --------------------------------------------------------------------------- let clipInProgress = false; const CLIP_SECONDS = 2; /* * Save a ~2-second clip centered on a flash, named Lunar__.. * * Because this app is entirely client-side, it cannot losslessly cut the * uploaded MP4 (no server / ffmpeg). Instead it plays the 2-second window and * captures the