Download app.js from AK51/MoonFlash: direct link, hf CLI and curl.
- Browser
- Download file 34.7 kB
-
https://huggingface.co/spaces/AK51/MoonFlash/resolve/main/app.js
- Command line
-
hf download hf://spaces/AK51/MoonFlash/app.js
-
curl -L -o app.js https://huggingface.co/spaces/AK51/MoonFlash/resolve/main/app.js
34.7 kB
| /* | |
| * 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 + "<br><span style='color:#9aa6c2'>peak " + f.peakFrame + "</span>"); | |
| tr.innerHTML = | |
| "<td><span class='tag-dot'></span>" + f.index + "</td>" + | |
| "<td>" + fmtTime(f.peakTime) + "<br><span style='color:#9aa6c2'>" + frameCell + "</span></td>" + | |
| "<td>" + f.x + ", " + f.y + "</td>" + | |
| "<td>" + fmtDuration(f.durationSec, f.frames) + "</td>" + | |
| "<td>" + f.bboxW + "×" + f.bboxH + " px<br><span style='color:#9aa6c2'>" + f.areaSrcPx + " px²</span></td>" + | |
| "<td>" + f.peakBrightness + "<br><span style='color:#9aa6c2'>+" + f.peakDelta + "</span></td>" + | |
| "<td></td>"; | |
| 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<br><span style='color:#9aa6c2'>" + frames + " frame" + (frames > 1 ? "s" : "") + "</span>"; | |
| } | |
| 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_<x>_<y>.<ext>. | |
| * | |
| * 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 <video> element's live stream with MediaRecorder, producing a | |
| * newly-encoded clip. It requests MP4 output and falls back to WebM if the | |
| * browser can't record MP4. Detection metadata (source time range, frames, | |
| * coordinates, size) is written into a sidecar .json alongside the clip, | |
| * since arbitrary original-container metadata can't be copied in-browser. | |
| */ | |
| function pickMimeType() { | |
| const candidates = [ | |
| "video/mp4;codecs=avc1.42E01E", | |
| "video/mp4", | |
| "video/webm;codecs=vp9", | |
| "video/webm;codecs=vp8", | |
| "video/webm", | |
| ]; | |
| if (typeof MediaRecorder === "undefined") return null; | |
| for (const t of candidates) { | |
| try { if (MediaRecorder.isTypeSupported(t)) return t; } catch (e) {} | |
| } | |
| return ""; | |
| } | |
| async function exportClip(f, btn) { | |
| if (clipInProgress) return; | |
| if (analyzing) { showToast("Wait for analysis to finish first."); return; } | |
| if (typeof video.captureStream !== "function" && typeof video.mozCaptureStream !== "function") { | |
| showToast("This browser can't capture video clips."); | |
| return; | |
| } | |
| const mime = pickMimeType(); | |
| if (mime === null) { | |
| showToast("This browser doesn't support MediaRecorder."); | |
| return; | |
| } | |
| clipInProgress = true; | |
| const wasPaused = video.paused; | |
| const resumeAt = video.currentTime; | |
| const origLabel = btn.textContent; | |
| btn.disabled = true; | |
| btn.textContent = "β¦"; | |
| const dur = video.duration || 0; | |
| const half = CLIP_SECONDS / 2; | |
| const startT = Math.max(0, f.peakTime - half); | |
| const endT = Math.min(dur, startT + CLIP_SECONDS); | |
| try { | |
| await seekTo(startT); | |
| const stream = (video.captureStream || video.mozCaptureStream).call(video); | |
| const ext = mime.indexOf("mp4") !== -1 ? "mp4" : "webm"; | |
| const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); | |
| const chunks = []; | |
| recorder.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); }; | |
| const stopped = new Promise((resolve) => { recorder.onstop = resolve; }); | |
| recorder.start(); | |
| // Play the window, then stop at endT. | |
| await video.play(); | |
| await new Promise((resolve) => { | |
| const check = () => { | |
| if (video.currentTime >= endT - 1e-3 || video.ended) resolve(); | |
| else requestAnimationFrame(check); | |
| }; | |
| requestAnimationFrame(check); | |
| }); | |
| video.pause(); | |
| if (recorder.state !== "inactive") recorder.stop(); | |
| await stopped; | |
| const blob = new Blob(chunks, { type: mime || "video/webm" }); | |
| const base = "Lunar_" + f.x + "_" + f.y; | |
| downloadBlob(blob, base + "." + ext); | |
| // Sidecar metadata JSON (detection details from the original video). | |
| const meta = { | |
| source_video_frame_size: { width: video.videoWidth, height: video.videoHeight }, | |
| flash_index: f.index, | |
| location_px: { x: f.x, y: f.y }, | |
| peak_time_s: Number(f.peakTime.toFixed(3)), | |
| clip_start_s: Number(startT.toFixed(3)), | |
| clip_end_s: Number(endT.toFixed(3)), | |
| start_frame: f.startFrame, | |
| peak_frame: f.peakFrame, | |
| end_frame: f.endFrame, | |
| flash_duration_ms: Math.round(f.durationSec * 1000), | |
| frames: f.frames, | |
| size_px: { width: f.bboxW, height: f.bboxH, area_px2: f.areaSrcPx }, | |
| peak_brightness: f.peakBrightness, | |
| peak_delta: f.peakDelta, | |
| clip_format: mime || "video/webm", | |
| note: "Clip is re-encoded from the source in-browser; not a lossless cut of the original.", | |
| }; | |
| downloadBlob(new Blob([JSON.stringify(meta, null, 2)], { type: "application/json" }), base + ".json"); | |
| showToast("Saved " + base + "." + ext + (ext === "webm" ? " (browser couldn't record MP4)" : "")); | |
| } catch (err) { | |
| console.error(err); | |
| showToast("Clip export failed: " + (err && err.message ? err.message : err)); | |
| } finally { | |
| // Restore playback state. | |
| try { | |
| await seekTo(resumeAt); | |
| if (!wasPaused) video.play(); | |
| } catch (e) {} | |
| btn.disabled = false; | |
| btn.textContent = origLabel; | |
| clipInProgress = false; | |
| renderOverlay(); | |
| } | |
| } | |
| function downloadBlob(blob, filename) { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = filename; | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Overlay rendering | |
| // --------------------------------------------------------------------------- | |
| /* | |
| * The video is drawn with object-fit: contain, so the actual picture is | |
| * letterboxed inside the overlay canvas. Compute that content rectangle | |
| * (offset + size) from the video's intrinsic aspect ratio, then map analysis | |
| * coordinates β which are proportional to the full video frame β into it. | |
| * This is what fixes markers landing in the wrong place on portrait video. | |
| */ | |
| function contentRect() { | |
| const cw = overlay.width, ch = overlay.height; | |
| const vw = video.videoWidth || analysisGeom.w || 1; | |
| const vh = video.videoHeight || analysisGeom.h || 1; | |
| const canvasAR = cw / ch; | |
| const videoAR = vw / vh; | |
| let dw, dh; | |
| if (videoAR > canvasAR) { | |
| // video wider than canvas -> full width, bars top/bottom | |
| dw = cw; dh = cw / videoAR; | |
| } else { | |
| // video taller -> full height, bars left/right | |
| dh = ch; dw = ch * videoAR; | |
| } | |
| const ox = (cw - dw) / 2; | |
| const oy = (ch - dh) / 2; | |
| return { ox, oy, dw, dh }; | |
| } | |
| // Map analysis-space (0..analysisGeom.w, 0..analysisGeom.h) to canvas px. | |
| function mapPoint(ax, ay) { | |
| const { ox, oy, dw, dh } = contentRect(); | |
| return [ox + (ax / (analysisGeom.w || 1)) * dw, oy + (ay / (analysisGeom.h || 1)) * dh]; | |
| } | |
| function renderOverlay(focus) { | |
| octx.clearRect(0, 0, overlay.width, overlay.height); | |
| if (!flashes.length || !showMarkers.checked) return; | |
| if (highlightPlayback.checked && !analyzing) { | |
| const t = video.currentTime; | |
| for (const f of flashes) { | |
| if (t >= f.startTime - 0.08 && t <= f.endTime + 0.08) drawMarker(f, true); | |
| } | |
| } | |
| for (const f of flashes) { | |
| if (focus && f === focus) continue; | |
| drawMarker(f, false, true); | |
| } | |
| if (focus) drawMarker(focus, true); | |
| } | |
| function drawMarker(f, strong, small) { | |
| const a = f._analysis; | |
| const { dw, dh } = contentRect(); | |
| const [cx, cy] = mapPoint(a.cx, a.cy); | |
| const sx = dw / (analysisGeom.w || 1); | |
| const sy = dh / (analysisGeom.h || 1); | |
| const bw = (a.bbox[2] - a.bbox[0] + 1) * sx; | |
| const bh = (a.bbox[3] - a.bbox[1] + 1) * sy; | |
| const r = Math.max(10, Math.max(bw, bh) * 0.7 + 8); | |
| octx.save(); | |
| octx.setLineDash([]); | |
| octx.lineWidth = strong ? 2.5 : 1.5; | |
| octx.strokeStyle = strong ? "#ffd479" : "rgba(110,168,254,0.9)"; | |
| if (strong) { octx.shadowColor = "#ffd479"; octx.shadowBlur = 16; } | |
| octx.beginPath(); | |
| octx.arc(cx, cy, r, 0, Math.PI * 2); | |
| octx.stroke(); | |
| octx.beginPath(); | |
| octx.moveTo(cx - r - 6, cy); octx.lineTo(cx - r + 4, cy); | |
| octx.moveTo(cx + r - 4, cy); octx.lineTo(cx + r + 6, cy); | |
| octx.moveTo(cx, cy - r - 6); octx.lineTo(cx, cy - r + 4); | |
| octx.moveTo(cx, cy + r - 4); octx.lineTo(cx, cy + r + 6); | |
| octx.stroke(); | |
| if (!small || strong) { | |
| octx.font = "12px Segoe UI, sans-serif"; | |
| octx.fillStyle = strong ? "#ffd479" : "rgba(110,168,254,0.9)"; | |
| octx.shadowBlur = strong ? 8 : 0; | |
| octx.fillText("#" + f.index, cx + r + 4, cy - r); | |
| } | |
| octx.restore(); | |
| } | |
| [highlightPlayback, showMarkers].forEach((el) => | |
| el.addEventListener("change", () => renderOverlay()) | |
| ); | |
| // --------------------------------------------------------------------------- | |
| // CSV export | |
| // --------------------------------------------------------------------------- | |
| exportBtn.addEventListener("click", () => { | |
| if (!flashes.length) return; | |
| const header = [ | |
| "index", "peak_time_s", "start_time_s", "end_time_s", | |
| "start_frame", "peak_frame", "end_frame", | |
| "duration_ms", "frames", "x_px", "y_px", | |
| "bbox_w_px", "bbox_h_px", "area_px2", "peak_brightness", "peak_delta", "aspect", | |
| ]; | |
| const rows = flashes.map((f) => [ | |
| f.index, f.peakTime.toFixed(3), f.startTime.toFixed(3), f.endTime.toFixed(3), | |
| f.startFrame, f.peakFrame, f.endFrame, | |
| Math.round(f.durationSec * 1000), f.frames, f.x, f.y, | |
| f.bboxW, f.bboxH, f.areaSrcPx, f.peakBrightness, f.peakDelta, f.aspect, | |
| ]); | |
| const csv = [header.join(","), ...rows.map((r) => r.join(","))].join("\n"); | |
| const blob = new Blob([csv], { type: "text/csv" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = "lunar_flashes.csv"; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Toast | |
| // --------------------------------------------------------------------------- | |
| let toastTimer = null; | |
| function showToast(msg) { | |
| toast.textContent = msg; | |
| toast.classList.remove("hidden"); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => toast.classList.add("hidden"), 3200); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Startup: preload the bundled sample video so users see it working right | |
| // away. They can still drop or browse to their own MP4 at any time via the | |
| // drop zone or the "Change file" button. | |
| // --------------------------------------------------------------------------- | |
| const SAMPLE_URL = "short_flash.mp4"; | |
| /* | |
| * Fetch the bundled sample as a Blob and load it via a blob: URL. Blob URLs | |
| * are always same-origin, so drawing the video to the analysis canvas never | |
| * taints it β this avoids the "canvas tainted by cross-origin data" error | |
| * that occurs on hosts (e.g. Hugging Face) which serve media through a | |
| * separate CDN origin. Small file (~1.2 MB), so the one-time fetch is cheap. | |
| */ | |
| function preloadSample() { | |
| fetch(SAMPLE_URL) | |
| .then((r) => (r.ok ? r.blob() : Promise.reject(new Error("sample not found")))) | |
| .then((blob) => { | |
| if (currentURL) URL.revokeObjectURL(currentURL); | |
| currentURL = URL.createObjectURL(blob); | |
| showVideo(currentURL); // blob: is same-origin, canvas never tainted | |
| showToast("Loaded sample: short_flash.mp4 β drop your own MP4 anytime"); | |
| }) | |
| .catch(() => { /* no sample available; keep the drop zone visible */ }); | |
| } | |
| preloadSample(); | |
| })(); | |