You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
506 lines
17 KiB
506 lines
17 KiB
import { appState } from "./state.js";
|
|
import { debugFlags } from "./debug.js";
|
|
import { saveFileWithMetadata, loadManualOffset, deleteManualOffset } from "./db.js";
|
|
import { parseVisualizationJson } from "./fileParsers.js";
|
|
import {
|
|
showLoadingModal,
|
|
updateLoadingModal,
|
|
hideModal,
|
|
showModal,
|
|
} from "./modal.js";
|
|
import {
|
|
precomputeRadarVideoSync,
|
|
extractTimestampInfo,
|
|
parseTimestamp,
|
|
} from "./utils.js";
|
|
import { resetVisualization } from "./sync.js";
|
|
import { radarSketch } from "./p5/radarSketch.js";
|
|
import { speedGraphSketch } from "./p5/speedGraphSketch.js";
|
|
import { zoomSketch } from "./p5/zoomSketch.js";
|
|
import {
|
|
videoPlayer,
|
|
videoPlaceholder,
|
|
canvasPlaceholder,
|
|
featureToggles,
|
|
speedGraphPlaceholder,
|
|
snrMinInput,
|
|
snrMaxInput,
|
|
autoOffsetIndicator,
|
|
offsetInput,
|
|
speedSlider,
|
|
updatePersistentOverlays,
|
|
updateDebugOverlay,
|
|
resetUIForNewLoad,
|
|
} from "./dom.js";
|
|
|
|
import { forceResyncWithOffset } from "./sync.js";
|
|
/**
|
|
* This is the main handler for both manual clicks and drag-and-drop.
|
|
* It identifies the files and triggers the unified processing pipeline.
|
|
*/
|
|
export function handleFiles(files, fromCache = false) {
|
|
// Identify new files from the input
|
|
let incomingJson = null;
|
|
let incomingVideo = null;
|
|
|
|
Array.from(files).forEach((file) => {
|
|
if (file.name.endsWith(".json")) {
|
|
incomingJson = file;
|
|
}
|
|
if (file.type.startsWith("video/")) {
|
|
incomingVideo = file;
|
|
}
|
|
});
|
|
|
|
// If no valid files were dropped, do nothing
|
|
if (!incomingJson && !incomingVideo) return;
|
|
|
|
// Trigger the pipeline with the identified files
|
|
processFilePipeline(incomingJson, incomingVideo, fromCache);
|
|
}
|
|
|
|
async function processFilePipeline(jsonFile, videoFile, fromCache) {
|
|
// 0. Reset the UI to a clean state before processing anything.
|
|
// Pass 'true' if a new video is present, 'false' if we should try to keep the old one.
|
|
const isNewVideo = !!videoFile;
|
|
resetUIForNewLoad(isNewVideo);
|
|
|
|
// 1. Show the unified loading modal.
|
|
showLoadingModal("Processing files...");
|
|
|
|
const cachePromises = [];
|
|
|
|
// --- PART A: Setup Filenames & Cache (Moved Up) ---
|
|
if (jsonFile) {
|
|
appState.jsonFilename = jsonFile.name;
|
|
localStorage.setItem("jsonFilename", appState.jsonFilename);
|
|
if (!fromCache) {
|
|
const savePromise = saveFileWithMetadata("json", jsonFile).catch((e) =>
|
|
console.warn(`Non-blocking cache save failed for JSON:`, e)
|
|
);
|
|
if (debugFlags.CACHE_BLOCKING) {
|
|
await savePromise;
|
|
} else {
|
|
cachePromises.push(savePromise);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (videoFile) {
|
|
appState.videoFilename = videoFile.name;
|
|
localStorage.setItem("videoFilename", appState.videoFilename);
|
|
// CRITICAL FIX: Reset the videoMissing flag when a new video is being loaded.
|
|
appState.videoMissing = false;
|
|
|
|
if (!fromCache) {
|
|
const savePromise = saveFileWithMetadata("video", videoFile).catch((e) =>
|
|
console.warn(`Non-blocking cache save failed for Video:`, e)
|
|
);
|
|
cachePromises.push(savePromise);
|
|
}
|
|
}
|
|
|
|
// --- PART B: Calculate Offset (Moved Up) ---
|
|
// Critical: This must run BEFORE JSON parsing so valid start times are available.
|
|
await calculateAndSetOffset();
|
|
|
|
// --- PART C: Handle JSON Parsing ---
|
|
if (jsonFile) {
|
|
// Reset old visualization data immediately
|
|
appState.vizData = null;
|
|
// Pause P5 loop to prevent errors while data is missing
|
|
if (appState.p5_instance) appState.p5_instance.noLoop();
|
|
|
|
// Parse JSON
|
|
const worker = new Worker("./src/parser.worker.js");
|
|
const parsedData = await new Promise((resolve, reject) => {
|
|
worker.onmessage = (e) => {
|
|
const { type, data, percent, message } = e.data;
|
|
if (type === "progress") {
|
|
updateLoadingModal(percent * 0.8, `Parsing JSON (${percent}%)...`);
|
|
} else if (type === "complete") {
|
|
worker.terminate();
|
|
resolve(data);
|
|
} else if (type === "error") {
|
|
worker.terminate();
|
|
reject(new Error(message));
|
|
}
|
|
};
|
|
worker.postMessage({ file: jsonFile });
|
|
});
|
|
|
|
// Post-process JSON with correct dates
|
|
const result = await parseVisualizationJson(
|
|
parsedData,
|
|
appState.radarStartTimeMs,
|
|
appState.videoStartDate
|
|
);
|
|
|
|
if (result.error) {
|
|
hideModal();
|
|
showModal(result.error);
|
|
return;
|
|
}
|
|
|
|
appState.vizData = result.data;
|
|
appState.globalMinSnr = result.minSnr;
|
|
appState.globalMaxSnr = result.maxSnr;
|
|
}
|
|
|
|
// --- PART D: Precompute Sync ---
|
|
// Bake the offset into the data (needs vizData from Part C and offset from Part B)
|
|
if (appState.vizData) {
|
|
precomputeRadarVideoSync(appState.vizData, appState.offset);
|
|
}
|
|
|
|
// --- PART E: Load Video (if new) ---
|
|
if (videoFile) {
|
|
const videoLoaded = await loadVideo(videoFile);
|
|
if (!videoLoaded) {
|
|
appState.videoMissing = true;
|
|
}
|
|
}
|
|
|
|
// --- PART F: Finalize UI ---
|
|
finalizeSetup();
|
|
|
|
// Hide modal only if the video didn't fail. If it failed, the video
|
|
// loader has already handled showing an error/choice modal.
|
|
if (!appState.videoMissing) {
|
|
updateLoadingModal(100, "Complete!");
|
|
setTimeout(hideModal, 300);
|
|
}
|
|
|
|
// Log the results of the non-blocking cache operations once they complete.
|
|
if (cachePromises.length > 0) {
|
|
Promise.allSettled(cachePromises).then((results) => {
|
|
console.log("Non-blocking cache operations finished:", results);
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
// Encapsulates the specific logic for loading a video file into the player
|
|
let retries = 0;
|
|
function loadVideo(file, isRetry = false) {
|
|
return new Promise(async (resolve) => {
|
|
let metadataLoaded = false;
|
|
let loadTimeout;
|
|
|
|
// Before creating a new URL, revoke the old one if it exists.
|
|
if (!isRetry && appState.videoObjectUrl) {
|
|
URL.revokeObjectURL(appState.videoObjectUrl);
|
|
appState.videoObjectUrl = null;
|
|
}
|
|
|
|
const fileURL = isRetry ? videoPlayer.src : URL.createObjectURL(file);
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(loadTimeout);
|
|
videoPlayer.removeEventListener("loadedmetadata", onMetadataLoaded);
|
|
videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough);
|
|
videoPlayer.removeEventListener("error", onError);
|
|
};
|
|
|
|
const onMetadataLoaded = () => {
|
|
metadataLoaded = true;
|
|
updateLoadingModal(95, "Finalizing visualization...");
|
|
};
|
|
|
|
const onCanPlayThrough = () => {
|
|
cleanup();
|
|
resolve(true);
|
|
};
|
|
|
|
const handleTimeout = async () => {
|
|
if (metadataLoaded) {
|
|
console.warn(
|
|
"Video 'canplaythrough' event timed out, but 'loadedmetadata' fired. Proceeding with playback."
|
|
);
|
|
appState.videoReadyByFallback = true;
|
|
cleanup();
|
|
resolve(true);
|
|
} else {
|
|
// Neither event fired, video is likely broken.
|
|
await hideModal(); // Hide the loading modal first and wait for it to finish.
|
|
const choice = await showModal(
|
|
"Video is taking too long to load. It might be corrupted.",
|
|
true, // isConfirm
|
|
{ ok: "Retry", cancel: "Continue without Video" }
|
|
);
|
|
|
|
if (choice) { // Retry
|
|
if (retries < debugFlags.VIDEO_LOAD_RETRIES) {
|
|
retries++;
|
|
console.log(`Retrying video load... (Attempt ${retries})`);
|
|
showLoadingModal(`Retrying video load...`);
|
|
videoPlayer.load(); // Tell the video element to re-fetch
|
|
resolve(loadVideo(file, true)); // Recurse
|
|
} else {
|
|
await showModal("Video load failed after multiple retries.");
|
|
resolve(false); // Failed to load
|
|
}
|
|
} else { // Continue without video
|
|
console.warn("User opted to continue without video.");
|
|
cleanup();
|
|
// Revoke URL to free memory if we're giving up on it
|
|
if (videoPlayer.src.startsWith('blob:')) {
|
|
URL.revokeObjectURL(videoPlayer.src);
|
|
appState.videoObjectUrl = null; // Clear from state
|
|
}
|
|
videoPlayer.src = "";
|
|
videoPlayer.classList.add("hidden");
|
|
videoPlaceholder.classList.remove("hidden");
|
|
resolve(false); // Signal that video is not loaded
|
|
}
|
|
}
|
|
};
|
|
|
|
const onError = async (e) => {
|
|
console.error("Video loading error:", e);
|
|
cleanup();
|
|
await hideModal(); // Await is CRITICAL to prevent a race condition with the next modal.
|
|
showModal("Error loading video file. It may be an unsupported format or corrupted.");
|
|
resolve(false);
|
|
};
|
|
|
|
// Attach listeners
|
|
videoPlayer.addEventListener("loadedmetadata", onMetadataLoaded, { once: true });
|
|
videoPlayer.addEventListener("canplaythrough", onCanPlayThrough, { once: true });
|
|
videoPlayer.addEventListener("error", onError, { once: true });
|
|
|
|
// Start the timeout
|
|
loadTimeout = setTimeout(handleTimeout, debugFlags.VIDEO_LOAD_TIMEOUT);
|
|
|
|
// Apply source only if it's not a retry
|
|
if (!isRetry) {
|
|
retries = 0; // Reset retry counter for new files
|
|
setupVideoPlayer(fileURL);
|
|
}
|
|
});
|
|
}
|
|
|
|
function finalizeSetup() {
|
|
// CRITICAL FIX: Always reset the visualization state before redrawing.
|
|
// This pauses the video and resets the timeline, ensuring a clean slate for the new data.
|
|
resetVisualization();
|
|
// 1. Manage Placeholders & Visibility
|
|
// If we have data (vizData), we show the canvas container.
|
|
if (appState.vizData) {
|
|
canvasPlaceholder.style.display = "none";
|
|
featureToggles.classList.remove("hidden");
|
|
} else {
|
|
// If there's no viz data (e.g., video-only load), hide the canvas
|
|
canvasPlaceholder.style.display = ""; // Show placeholder
|
|
featureToggles.classList.add("hidden");
|
|
if (appState.p5_instance) {
|
|
appState.p5_instance.noLoop();
|
|
}
|
|
// If we don't have data yet (video only), we might keep the placeholder or show an empty canvas?
|
|
// Current behavior: keep placeholder until JSON loads.
|
|
}
|
|
|
|
// 2. Initialize/Update P5 Sketches
|
|
// We check if they exist; if not, create them. If they do, they will read the new appState on next draw.
|
|
if (!appState.p5_instance) {
|
|
appState.p5_instance = new p5(radarSketch);
|
|
} else {
|
|
// If it existed, ensure it's up to date.
|
|
// CRITICAL: Do NOT call .loop(). The app uses a custom animationLoop in sync.js.
|
|
appState.p5_instance.redraw();
|
|
}
|
|
|
|
if (!appState.zoomSketchInstance) {
|
|
appState.zoomSketchInstance = new p5(zoomSketch, "zoom-canvas-container");
|
|
}
|
|
|
|
// 3. Setup Speed Graph
|
|
if (appState.vizData) {
|
|
speedGraphPlaceholder.classList.add("hidden");
|
|
|
|
if (!appState.speedGraphInstance) {
|
|
appState.speedGraphInstance = new p5(speedGraphSketch);
|
|
}
|
|
|
|
// Update speed graph with new data + video duration
|
|
// Determine the most appropriate duration for the graph's X-axis.
|
|
let finalDuration = 0;
|
|
let jsonDuration = 0;
|
|
|
|
// 1. Calculate duration from the JSON data itself as a reliable baseline.
|
|
if (appState.vizData.radarFrames && appState.vizData.radarFrames.length > 0) {
|
|
const lastFrame = appState.vizData.radarFrames[appState.vizData.radarFrames.length - 1];
|
|
jsonDuration = lastFrame.timestamp / 1000.0;
|
|
}
|
|
|
|
// 2. Get video duration, normalizing invalid values.
|
|
let videoDuration = appState.videoMissing ? 0 : (videoPlayer.duration || 0);
|
|
if (!videoDuration || isNaN(videoDuration) || videoDuration <= 0) {
|
|
videoDuration = 0;
|
|
}
|
|
|
|
// 3. Set the graph's duration. Prioritize JSON duration, but clip it
|
|
// to the video's duration if a video is present and shorter.
|
|
finalDuration = jsonDuration;
|
|
if (videoDuration > 0 && jsonDuration > videoDuration) {
|
|
finalDuration = jsonDuration;
|
|
}
|
|
|
|
appState.speedGraphInstance.setData(appState.vizData, finalDuration);
|
|
appState.speedGraphInstance.redraw();
|
|
}
|
|
|
|
// 4. Update UI Overlays
|
|
// Manually update overlays so they are visible immediately.
|
|
updatePersistentOverlays(videoPlayer.currentTime);
|
|
updateDebugOverlay(videoPlayer.currentTime);
|
|
|
|
// 5. Update SNR Inputs
|
|
if (appState.vizData) {
|
|
snrMinInput.value = appState.globalMinSnr.toFixed(1);
|
|
snrMaxInput.value = appState.globalMaxSnr.toFixed(1);
|
|
}
|
|
}
|
|
|
|
// Sets up the video player with the given file URL.
|
|
function setupVideoPlayer(fileURL) {
|
|
videoPlayer.src = fileURL;
|
|
videoPlayer.classList.remove("hidden");
|
|
videoPlaceholder.classList.add("hidden");
|
|
videoPlayer.playbackRate = parseFloat(speedSlider.value);
|
|
|
|
// Store the new object URL if it's a blob
|
|
if (fileURL.startsWith("blob:")) {
|
|
appState.videoObjectUrl = fileURL;
|
|
}
|
|
}
|
|
|
|
async function calculateAndSetOffset() {
|
|
const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename);
|
|
const videoTimestampInfo = extractTimestampInfo(appState.videoFilename);
|
|
|
|
let videoDate = null;
|
|
if (videoTimestampInfo) {
|
|
videoDate = parseTimestamp(
|
|
videoTimestampInfo.timestampStr,
|
|
videoTimestampInfo.format
|
|
);
|
|
appState.videoStartDate = videoDate; // Store for potential future use
|
|
}
|
|
|
|
let jsonDate = null;
|
|
if (jsonTimestampInfo) {
|
|
jsonDate = parseTimestamp(
|
|
jsonTimestampInfo.timestampStr,
|
|
jsonTimestampInfo.format
|
|
);
|
|
}
|
|
|
|
// 1. Try to load a manually saved offset for this specific file pair.
|
|
// We use the JSON filename as the primary key, but ideally, it should be a combo.
|
|
// For now, sticking to the user request: "if the user uploads a similarly named file".
|
|
// We'll use the JSON filename as the key.
|
|
const savedOffset = await loadManualOffset(appState.jsonFilename);
|
|
|
|
if (savedOffset !== null) {
|
|
console.log(`Applying saved manual offset: ${savedOffset}ms`);
|
|
appState.offset = savedOffset;
|
|
if (jsonDate) {
|
|
appState.radarStartTimeMs = jsonDate.getTime();
|
|
}
|
|
// Update UI
|
|
offsetInput.value = appState.offset;
|
|
|
|
// Show "Manual" indicator
|
|
autoOffsetIndicator.textContent = "Manual";
|
|
autoOffsetIndicator.className = "text-xs font-bold ml-2 text-gray-500"; // Gray for manual
|
|
autoOffsetIndicator.classList.remove("hidden");
|
|
|
|
localStorage.setItem("visualizerOffset", appState.offset);
|
|
return; // Exit early, skipping auto-calc
|
|
}
|
|
|
|
let calculatedOffset = 0;
|
|
// We need both dates to calculate an offset.
|
|
if (jsonDate && videoDate) {
|
|
appState.radarStartTimeMs = jsonDate.getTime();
|
|
const offset = jsonDate.getTime() - videoDate.getTime();
|
|
|
|
if (isNaN(offset) || Math.abs(offset) > 30000) {
|
|
console.warn(`Calculated offset of ${offset}ms is invalid or exceeds 30s threshold. Defaulting to 0.`);
|
|
calculatedOffset = 0;
|
|
|
|
// Show "Default" or "Out of Range" indicator
|
|
autoOffsetIndicator.textContent = isNaN(offset) ? "Default" : "Out of Range";
|
|
autoOffsetIndicator.className = "text-xs font-bold ml-2 text-yellow-600"; // Dark Yellow
|
|
autoOffsetIndicator.classList.remove("hidden");
|
|
|
|
} else {
|
|
calculatedOffset = offset;
|
|
|
|
// Show "Auto" indicator
|
|
autoOffsetIndicator.textContent = "Auto";
|
|
autoOffsetIndicator.className = "text-xs font-bold ml-2 text-green-500"; // Green
|
|
autoOffsetIndicator.classList.remove("hidden");
|
|
|
|
console.log(`Auto-calculated offset: ${calculatedOffset} ms`);
|
|
}
|
|
} else if (jsonDate) {
|
|
// If we have JSON but no video, we set start time but offset is 0
|
|
appState.radarStartTimeMs = jsonDate.getTime();
|
|
// No specific indicator needed for JSON-only default 0, or could show "Default"
|
|
autoOffsetIndicator.classList.add("hidden");
|
|
}
|
|
|
|
appState.offset = calculatedOffset;
|
|
offsetInput.value = appState.offset;
|
|
localStorage.setItem("visualizerOffset", appState.offset);
|
|
}
|
|
|
|
/**
|
|
* Re-calculates and applies the automatic offset based on filenames.
|
|
* This function is triggered by user actions like clicking the 'Manual' indicator
|
|
* or using a keyboard shortcut to revert a manual offset.
|
|
*/
|
|
export function revertToAutoOffset() {
|
|
// 1. Calculate the automatic offset.
|
|
const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename);
|
|
const videoTimestampInfo = extractTimestampInfo(appState.videoFilename);
|
|
|
|
let calculatedOffset = 0;
|
|
let indicatorText = "Default";
|
|
let indicatorClass = "text-xs font-bold ml-2 text-yellow-600"; // Default to yellow
|
|
|
|
if (jsonTimestampInfo && videoTimestampInfo) {
|
|
const jsonDate = parseTimestamp(jsonTimestampInfo.timestampStr, jsonTimestampInfo.format);
|
|
const videoDate = parseTimestamp(videoTimestampInfo.timestampStr, videoTimestampInfo.format);
|
|
|
|
if (jsonDate && videoDate) {
|
|
const offset = jsonDate.getTime() - videoDate.getTime();
|
|
if (isNaN(offset) || Math.abs(offset) > 30000) {
|
|
calculatedOffset = 0;
|
|
indicatorText = "Out of Range";
|
|
} else {
|
|
calculatedOffset = offset;
|
|
indicatorText = "Auto";
|
|
indicatorClass = "text-xs font-bold ml-2 text-green-500";
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Update the input box with the new value.
|
|
offsetInput.value = calculatedOffset;
|
|
|
|
// 3. Delete any saved manual offset so future loads default to "Auto" logic.
|
|
if (appState.jsonFilename) {
|
|
deleteManualOffset(appState.jsonFilename);
|
|
}
|
|
|
|
// 4. Call the resync function with saveToDb = false.
|
|
forceResyncWithOffset(false);
|
|
|
|
// 5. After resyncing, set the correct indicator text and style.
|
|
autoOffsetIndicator.textContent = indicatorText;
|
|
autoOffsetIndicator.className = indicatorClass;
|
|
autoOffsetIndicator.classList.remove("hidden");
|
|
}
|