MoonFlash / app.js
AK51's picture
Upload 3 files
3c88ab4 verified
Raw History Blame Contribute Delete
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 + "&times;" + f.bboxH + " px<br><span style='color:#9aa6c2'>" + f.areaSrcPx + " px&sup2;</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();
})();