// Main Entry Point - Wire up all event handlers and run the application document.addEventListener("DOMContentLoaded", () => { // Shortcuts const { state } = APP.core; const { $, $$ } = APP.core.utils; const { log, setStatus, setHfStatus } = APP.ui.logging; const { hfDetectAsync, checkJobStatus, cancelBackendJob, pollAsyncJob } = APP.api.client; // Core modules const { captureFirstFrame, drawFirstFrame, unloadVideo, toggleDepthView, toggleFirstFrameDepthView, toggleProcessedFeed, resizeOverlays, setStreamingMode, stopStreamingMode, displayProcessedFirstFrame } = APP.core.video; const { syncKnobDisplays, recomputeHEL } = APP.core.hel; const { load: loadDemo, getFrameData: getDemoFrameData, enable: enableDemo } = APP.core.demo; // UI Renderers const { renderFrameRadar, renderLiveRadar } = APP.ui.radar; const { renderFrameOverlay, renderEngageOverlay } = APP.ui.overlays; const { renderFrameTrackList } = APP.ui.cards; const { renderFeatures } = APP.ui.features; const { renderTrade, populateTradeTarget, snapshotTrade } = APP.ui.trade; const { computeIntelSummary, resetIntelUI, renderMissionContext } = APP.ui.intel; const { tickAgentCursor, moveCursorToRect } = APP.ui.cursor; const { matchAndUpdateTracks, predictTracks } = APP.core.tracker; const { defaultAimpoint } = APP.core.physics; const { normBBox } = APP.core.utils; // DOM Elements const videoEngage = $("#videoEngage"); const videoHidden = $("#videoHidden"); const videoFile = $("#videoFile"); const btnReason = $("#btnReason"); const btnCancelReason = $("#btnCancelReason"); const btnRecompute = $("#btnRecompute"); const btnClear = $("#btnClear"); const btnEject = $("#btnEject"); const btnEngage = $("#btnEngage"); const btnReset = $("#btnReset"); const btnPause = $("#btnPause"); const btnToggleSidebar = $("#btnToggleSidebar"); const btnIntelRefresh = $("#btnIntelRefresh"); const btnReplot = $("#btnReplot"); const btnSnap = $("#btnSnap"); const detectorSelect = $("#detectorSelect"); const missionText = $("#missionText"); const cursorMode = $("#cursorMode"); const frameCanvas = $("#frameCanvas"); const frameTrackList = $("#frameTrackList"); const frameEmpty = $("#frameEmpty"); const frameNote = $("#frameNote"); const engageEmpty = $("#engageEmpty"); const engageNote = $("#engageNote"); const chipFeed = $("#chipFeed"); const chipDepth = $("#chipDepth"); const chipFrameDepth = $("#chipFrameDepth"); // Initialization function init() { log("System initializing...", "t"); setupFileUpload(); setupControls(); setupKnobListeners(); setupChipToggles(); setupTabSwitching(); // Initial UI sync syncKnobDisplays(); renderMissionContext(); setHfStatus("idle"); // Start main loop requestAnimationFrame(loop); // Load demo data (if available) loadDemo().then(() => { // hidden usage: enable if video filename matches "demo" or manually // APP.core.demo.enable(true); }); log("System READY.", "g"); } function setupFileUpload() { if (!videoFile) return; videoFile.addEventListener("change", async (e) => { const file = e.target.files[0]; if (!file) return; state.videoFile = file; state.videoUrl = URL.createObjectURL(file); state.videoLoaded = true; // Show meta const videoMeta = $("#videoMeta"); if (videoMeta) videoMeta.textContent = file.name; // Load video into engage player if (videoEngage) { videoEngage.src = state.videoUrl; videoEngage.load(); } // Hide empty states if (engageEmpty) engageEmpty.style.display = "none"; // Capture first frame dimensions (but don't draw - wait for processed frame from backend) try { await captureFirstFrame(); // Show placeholder message - actual frame will come from backend if (frameNote) frameNote.textContent = "Video loaded (run Reason for processed frame)"; if (engageNote) engageNote.textContent = "Ready for Engage"; } catch (err) { log(`First frame capture failed: ${err.message}`, "e"); } setStatus("warn", "READY · Video loaded (run Reason)"); log(`Video loaded: ${file.name}`, "g"); // Load video-specific demo tracks (e.g., helicopter demo) if (APP.core.demo.loadForVideo) { await APP.core.demo.loadForVideo(file.name); } // Auto-enable demo mode if filename contains "demo" or helicopter video const shouldEnableDemo = file.name.toLowerCase().includes("demo") || file.name.toLowerCase().includes("enhance_video_movement"); if (shouldEnableDemo && APP.core.demo.data) { enableDemo(true); log("Auto-enabled DEMO mode for this video.", "g"); } }); } function setupControls() { // Reason button if (btnReason) { btnReason.addEventListener("click", runReason); } // Cancel Reason button if (btnCancelReason) { btnCancelReason.addEventListener("click", cancelReasoning); } // Recompute HEL button if (btnRecompute) { btnRecompute.addEventListener("click", async () => { if (!state.hasReasoned) return; await recomputeHEL(); renderFrameOverlay(); renderTrade(); log("HEL parameters recomputed.", "g"); }); } // Clear button if (btnClear) { btnClear.addEventListener("click", () => { state.detections = []; state.selectedId = null; renderFrameTrackList(); renderFrameOverlay(); renderFeatures(null); renderTrade(); log("Detections cleared.", "t"); }); } // Eject button if (btnEject) { btnEject.addEventListener("click", async () => { await unloadVideo(); }); } // Engage button if (btnEngage) { btnEngage.addEventListener("click", runEngage); } // Pause button if (btnPause) { btnPause.addEventListener("click", () => { if (videoEngage) videoEngage.pause(); state.tracker.running = false; log("Engage paused.", "t"); }); } // Reset button if (btnReset) { btnReset.addEventListener("click", () => { if (videoEngage) { videoEngage.pause(); videoEngage.currentTime = 0; } state.tracker.tracks = []; state.tracker.running = false; state.tracker.nextId = 1; renderFrameTrackList(); renderFrameRadar(); renderLiveRadar(); log("Engage reset.", "t"); }); } // Sidebar toggle (Tab 2) if (btnToggleSidebar) { btnToggleSidebar.addEventListener("click", () => { const engageGrid = $(".engage-grid"); if (engageGrid) { engageGrid.classList.toggle("sidebar-collapsed"); btnToggleSidebar.textContent = engageGrid.classList.contains("sidebar-collapsed") ? "▶ Show Sidebar" : "◀ Hide Sidebar"; } }); } // Intel refresh if (btnIntelRefresh) { btnIntelRefresh.addEventListener("click", async () => { if (!state.videoLoaded) return; log("Refreshing mission intel summary...", "t"); await computeIntelSummary(); }); } // Trade space controls if (btnReplot) { btnReplot.addEventListener("click", renderTrade); } if (btnSnap) { btnSnap.addEventListener("click", snapshotTrade); } const tradeTarget = $("#tradeTarget"); if (tradeTarget) { tradeTarget.addEventListener("change", renderTrade); } // Track selection event document.addEventListener("track-selected", (e) => { state.selectedId = e.detail.id; state.tracker.selectedTrackId = e.detail.id; renderFrameTrackList(); renderFrameOverlay(); const det = state.detections.find(d => d.id === state.selectedId); renderFeatures(det); }); // Cursor mode toggle if (cursorMode) { cursorMode.addEventListener("change", () => { state.ui.cursorMode = cursorMode.value; if (state.ui.cursorMode === "off" && APP.ui.cursor.setCursorVisible) { APP.ui.cursor.setCursorVisible(false); } }); } } function setupKnobListeners() { // Listen to all inputs and selects for knob updates const inputs = Array.from(document.querySelectorAll("input, select")); inputs.forEach(el => { el.addEventListener("input", () => { syncKnobDisplays(); if (state.hasReasoned) { recomputeHEL(); renderFrameOverlay(); renderTrade(); } }); }); // Initial sync syncKnobDisplays(); } function setupChipToggles() { // Toggle processed/raw feed if (chipFeed) { chipFeed.style.cursor = "pointer"; chipFeed.addEventListener("click", () => { if (!state.videoLoaded) return; toggleProcessedFeed(); log(`Feed set to: ${state.useProcessedFeed ? "HF" : "RAW"}`, "t"); }); } // Toggle depth view (Tab 2) if (chipDepth) { chipDepth.style.cursor = "pointer"; chipDepth.addEventListener("click", () => { if (!state.videoLoaded) return; toggleDepthView(); log(`Engage view set to: ${state.useDepthFeed ? "DEPTH" : "DEFAULT"}`, "t"); }); } // Toggle first frame depth view (Tab 1) if (chipFrameDepth) { chipFrameDepth.style.cursor = "pointer"; chipFrameDepth.addEventListener("click", () => { if (!state.videoLoaded) return; if (!state.hf.depthFirstFrameUrl) { log("First frame depth not ready yet. Run Reason and wait for depth processing.", "w"); return; } toggleFirstFrameDepthView(); log(`First frame view set to: ${state.useFrameDepthView ? "DEPTH" : "DEFAULT"}`, "t"); }); } } function setupTabSwitching() { const tabs = Array.from(document.querySelectorAll(".tabbtn")); tabs.forEach(btn => { btn.addEventListener("click", () => { tabs.forEach(b => b.classList.remove("active")); document.querySelectorAll(".tab").forEach(t => t.classList.remove("active")); btn.classList.add("active"); const tabId = `#tab-${btn.dataset.tab}`; const tab = $(tabId); if (tab) tab.classList.add("active"); // Tab-specific actions if (btn.dataset.tab === "trade") { populateTradeTarget(); renderTrade(); } if (btn.dataset.tab === "engage") { resizeOverlays(); renderLiveRadar(); } }); }); } async function runReason() { if (!state.videoLoaded) { log("No video loaded. Upload a video first.", "w"); setStatus("warn", "READY · Upload a video"); return; } if (state.isReasoning) { log("Reason already in progress. Please wait.", "w"); return; } // Lock the Reason process state.isReasoning = true; if (btnReason) { btnReason.disabled = true; btnReason.style.opacity = "0.5"; btnReason.style.cursor = "not-allowed"; } if (btnCancelReason) btnCancelReason.style.display = "inline-block"; if (btnEngage) btnEngage.disabled = true; // Clear previous detections state.detections = []; state.selectedId = null; renderFrameTrackList(); renderFrameOverlay(); renderFeatures(null); renderTrade(); setStatus("warn", "REASONING · Running perception pipeline"); // Agent cursor flair if (state.ui.cursorMode === "on" && moveCursorToRect) { if (btnReason) moveCursorToRect(btnReason.getBoundingClientRect()); if (frameCanvas) setTimeout(() => moveCursorToRect(frameCanvas.getBoundingClientRect()), 260); if (frameTrackList) setTimeout(() => moveCursorToRect(frameTrackList.getBoundingClientRect()), 560); } try { const mode = detectorSelect ? detectorSelect.value : "hf_yolov8"; const queries = missionText ? missionText.value.trim() : ""; const enableGPT = $("#enableGPTToggle")?.checked || false; const enableDepth = $("#enableDepthToggle")?.checked || false; const form = new FormData(); form.append("video", state.videoFile); form.append("mode", "object_detection"); if (queries) form.append("queries", queries); form.append("detector", mode); form.append("enable_gpt", enableGPT ? "true" : "false"); form.append("enable_depth", enableDepth ? "true" : "false"); log(`Submitting job to ${state.hf.baseUrl}...`, "t"); setHfStatus("submitting job..."); const data = await hfDetectAsync(form); state.hf.asyncJobId = data.job_id; // Store raw detections (will process after image loads to get correct dimensions) const rawDetections = data.first_frame_detections || []; // Display processed first frame from backend (only processed frame, not raw) // This is async - image loading will update state.frame.w/h if (data.first_frame_url) { state.hf.firstFrameUrl = data.first_frame_url.startsWith("http") ? data.first_frame_url : `${state.hf.baseUrl}${data.first_frame_url}`; // Wait for image to load so we have correct dimensions before processing detections await new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { // Update frame dimensions from loaded image state.frame.w = img.naturalWidth || 1280; state.frame.h = img.naturalHeight || 720; // Resize canvases to match const frameCanvas = $("#frameCanvas"); const frameOverlay = $("#frameOverlay"); if (frameCanvas) { frameCanvas.width = state.frame.w; frameCanvas.height = state.frame.h; frameCanvas.getContext("2d").drawImage(img, 0, 0, state.frame.w, state.frame.h); } if (frameOverlay) { frameOverlay.width = state.frame.w; frameOverlay.height = state.frame.h; } // Hide empty state const frameEmpty = $("#frameEmpty"); const frameNote = $("#frameNote"); if (frameEmpty) frameEmpty.style.display = "none"; if (frameNote) frameNote.textContent = "Processed (from backend)"; log(`Processed first frame displayed (${state.frame.w}×${state.frame.h})`, "g"); resolve(); }; img.onerror = () => { log("Failed to load processed first frame, using local frame", "w"); drawFirstFrame(); resolve(); }; img.src = state.hf.firstFrameUrl; }); } // NOW process detections (after frame dimensions are correct) if (rawDetections.length > 0) { processFirstFrameDetections(rawDetections); } // Mark first frame as ready (for radar display) state.firstFrameReady = true; // Store depth URLs if provided if (data.depth_video_url) { state.hf.depthVideoUrl = data.depth_video_url.startsWith("http") ? data.depth_video_url : `${state.hf.baseUrl}${data.depth_video_url}`; log("Depth video URL received", "t"); } if (data.first_frame_depth_url) { state.hf.depthFirstFrameUrl = data.first_frame_depth_url.startsWith("http") ? data.first_frame_depth_url : `${state.hf.baseUrl}${data.first_frame_depth_url}`; log("First frame depth URL received", "t"); } // Enable streaming mode if stream_url is provided (Tab 2 live view) const enableStream = $("#enableStreamToggle")?.checked; if (data.stream_url && enableStream) { const streamUrl = data.stream_url.startsWith("http") ? data.stream_url : `${state.hf.baseUrl}${data.stream_url}`; log("Activating live stream...", "t"); setStreamingMode(streamUrl); log("Live view available in 'Engage' tab.", "g"); setStatus("warn", "Live processing... View in Engage tab"); // Trigger resize/render for Tab 2 resizeOverlays(); renderLiveRadar(); } // Start polling for completion pollAsyncJob().then(() => { log("Video processing complete.", "g"); // Stop streaming mode once video is ready stopStreamingMode(); state.hasReasoned = true; setStatus("good", "READY · Reason complete (you can Engage)"); log("Reason complete. Ready to Engage.", "g"); // Seed tracks for Tab 2 seedTracksFromTab1(); renderFrameRadar(); // Generate intel summary (async) computeIntelSummary(); }).catch(err => { log(`Polling error: ${err.message}`, "e"); stopStreamingMode(); }); // Initial status (processing in background) setStatus("warn", "PROCESSING · Analysing video..."); log("Reasoning started (processing in background)...", "t"); } catch (err) { setStatus("bad", "ERROR · Reason failed"); log(`Reason failed: ${err.message}`, "e"); console.error(err); } finally { state.isReasoning = false; if (btnReason) { btnReason.disabled = false; btnReason.style.opacity = "1"; btnReason.style.cursor = "pointer"; } if (btnCancelReason) btnCancelReason.style.display = "none"; } } function processFirstFrameDetections(dets) { state.detections = dets.map((d, i) => { const id = `T${String(i + 1).padStart(2, "0")}`; const ap = defaultAimpoint(d.label || d.class); const bbox = d.bbox ? { x: d.bbox[0], y: d.bbox[1], w: d.bbox[2] - d.bbox[0], h: d.bbox[3] - d.bbox[1] } : { x: 0, y: 0, w: 10, h: 10 }; return { id, label: d.label || d.class, score: d.score || 0.5, bbox, aim: { ...ap }, aim: { ...ap }, features: d.gpt_raw ? { "Vessel Class": d.gpt_raw.specific_class || d.gpt_raw.vessel_category || "Unknown", "Threat Lvl": d.gpt_raw.threat_level_score + "/10", "Status": d.gpt_raw.threat_classification || "?", "Weapons": (d.gpt_raw.visible_weapons || []).join(", ") || "None Visible", "Readiness": d.gpt_raw.weapon_readiness || "Unknown", "Motion": d.gpt_raw.motion_status || "Unknown", "Sensors": (d.gpt_raw.sensor_profile || []).join(", ") || "None", "Flags/ID": (d.gpt_raw.identity_markers || []).join(", ") || (d.gpt_raw.flag_state || "Unknown"), "Activity": d.gpt_raw.deck_activity || "None", "Range": (d.gpt_raw.range_estimation_nm ? d.gpt_raw.range_estimation_nm + " NM" : "Unknown"), "Wake": d.gpt_raw.wake_description || "None" } : {}, baseRange_m: null, baseAreaFrac: (bbox.w * bbox.h) / (state.frame.w * state.frame.h), baseDwell_s: 5.0, reqP_kW: 40, maxP_kW: 0, pkill: 0, // New depth fields depth_est_m: (d.depth_est_m !== undefined && d.depth_est_m !== null) ? d.depth_est_m : null, depth_rel: (d.depth_rel !== undefined && d.depth_rel !== null) ? d.depth_rel : null, depth_valid: d.depth_valid ?? false, gpt_distance_m: d.gpt_distance_m || null, gpt_direction: d.gpt_direction || null, gpt_description: d.gpt_description || null, // New Threat Intelligence threat_level_score: d.threat_level_score || 0, threat_classification: d.threat_classification || "Unknown", weapon_readiness: d.weapon_readiness || "Unknown" }; }); state.selectedId = state.detections[0]?.id || null; renderFrameTrackList(); renderFeatures(state.detections[0] || null); renderFrameOverlay(); log(`Detected ${state.detections.length} objects in first frame.`, "g"); } function seedTracksFromTab1() { const rangeBase = $("#rangeBase"); state.tracker.tracks = state.detections.map(d => ({ id: d.id, label: d.label, bbox: { ...d.bbox }, score: d.score, aimRel: d.aim ? { relx: d.aim.relx, rely: d.aim.rely, label: d.aim.label } : { relx: 0.5, rely: 0.5, label: "center_mass" }, baseAreaFrac: d.baseAreaFrac || ((d.bbox.w * d.bbox.h) / (state.frame.w * state.frame.h)), baseRange_m: d.baseRange_m || (rangeBase ? +rangeBase.value : 1500), baseDwell_s: d.baseDwell_s || 4.0, reqP_kW: d.reqP_kW || 35, depth_rel: d.depth_rel, depth_est_m: d.depth_est_m, depth_valid: d.depth_valid, lastDepthBbox: d.depth_valid ? { ...d.bbox } : null, gpt_distance_m: d.gpt_distance_m, gpt_direction: d.gpt_direction, gpt_description: d.gpt_description, lastSeen: APP.core.utils.now(), vx: 0, vy: 0, dwellAccum: 0, killed: false, state: "TRACK", assessT: 0 })); state.tracker.nextId = state.detections.length + 1; log(`Seeded ${state.tracker.tracks.length} tracks from Tab 1 detections.`, "t"); } function cancelReasoning() { // Stop HF polling if running if (state.hf.asyncPollInterval) { clearInterval(state.hf.asyncPollInterval); state.hf.asyncPollInterval = null; log("HF polling stopped.", "w"); } // Stop streaming mode stopStreamingMode(); // Cancel backend job if it exists const jobId = state.hf.asyncJobId; if (jobId) { cancelBackendJob(jobId, "cancel button"); } // Reset state state.isReasoning = false; state.hf.busy = false; state.hf.asyncJobId = null; state.hf.asyncStatus = "cancelled"; // Re-enable Reason button if (btnReason) { btnReason.disabled = false; btnReason.style.opacity = "1"; btnReason.style.cursor = "pointer"; } if (btnCancelReason) btnCancelReason.style.display = "none"; setStatus("warn", "CANCELLED · Reasoning stopped"); setHfStatus("cancelled (stopped by user)"); log("Reasoning cancelled by user.", "w"); } function runEngage() { if (!state.hasReasoned) { log("Please run Reason first.", "w"); return; } if (state.hf.asyncJobId) { log("Processing still in progress. Please wait.", "w"); // If we are streaming, make sure we are on the engage tab to see it const engageTab = $(`.tabbtn[data-tab="engage"]`); if (engageTab) engageTab.click(); return; } // Switch to engage tab const engageTab = $(`.tabbtn[data-tab="engage"]`); if (engageTab) engageTab.click(); // Set video source if (videoEngage) { videoEngage.src = state.hf.processedUrl || state.videoUrl; videoEngage.play().catch(err => { log(`Video playback failed: ${err.message}`, "e"); }); } state.tracker.running = true; state.tracker.lastFrameTime = APP.core.utils.now(); // Ensure tracks are seeded if (state.tracker.tracks.length === 0) { seedTracksFromTab1(); } log("Engage sequence started.", "g"); } function loop() { const { now } = APP.core.utils; const t = now(); // Guard against huge dt on first frame if (state.tracker.lastFrameTime === 0) state.tracker.lastFrameTime = t; const dt = Math.min((t - state.tracker.lastFrameTime) / 1000, 0.1); state.tracker.lastFrameTime = t; // Update tracker when engaged if (state.tracker.running && videoEngage && !videoEngage.paused) { // DEMO MODE BYPASS if (APP.core.demo.active && APP.core.demo.data) { const demoTracks = getDemoFrameData(videoEngage.currentTime); if (demoTracks) { // Deep clone to avoid mutating source data const tracksClone = JSON.parse(JSON.stringify(demoTracks)); state.tracker.tracks = tracksClone.map(d => ({ ...d, // Ensure defaults lastSeen: t, state: "TRACK", depth_valid: true, depth_est_m: d.gpt_distance_m || 1000, })); // Normalize if needed (frontend usually expects 0..1) const w = videoEngage.videoWidth || state.frame.w || 1280; const h = videoEngage.videoHeight || state.frame.h || 720; state.tracker.tracks.forEach(tr => { // Check if inputs are absolute pixels (if x > 1 or w > 1) // We assume demo data is in pixels (as per spec) if (tr.bbox.x > 1 || tr.bbox.w > 1) { tr.bbox.x /= w; tr.bbox.y /= h; tr.bbox.w /= w; tr.bbox.h /= h; } // Note: history in 'tr' is also in pixels in the source JSON. // But we don't normalize history here because radar.js currently handles raw pixels for history? // Actually, we should probably standardize everything to normalized if possible, // but let's check radar.js first. }); } else { // NORMAL MODE predictTracks(dt); // Sync with backend every few frames (approx 5Hz) if (t - state.tracker.lastHFSync > 200) { // Estimate frame index const fps = 30; // hardcoded for now, ideal: state.fps const frameIdx = Math.floor(videoEngage.currentTime * fps); // Only sync if we have a job ID if (state.hf.asyncJobId) { APP.core.tracker.syncWithBackend(frameIdx); } state.tracker.lastHFSync = t; } } } } // End if(running) // Render UI if (renderFrameRadar) renderFrameRadar(); if (renderLiveRadar) renderLiveRadar(); if (renderFrameOverlay) renderFrameOverlay(); if (renderEngageOverlay) renderEngageOverlay(); if (tickAgentCursor) tickAgentCursor(); requestAnimationFrame(loop); } // Expose state for debugging window.__LP_STATE__ = state; // Start init(); });