/*
* 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 =
"