Browse Source

feat(sync): hybrid architecture for video/radar synchronization

The previous synchronization logic relied on either naive relative time (causing drift) or expensive runtime date parsing (causing lag). This commit implements a hybrid "Baked Offset" architecture.

Changes:
- src/utils.js: Added `precomputeRadarVideoSync` to inject `videoSyncedTime` into every radar frame. Ported robust timestamp parsers from the modularize branch.
- src/main.js: Integrated automatic offset calculation into the file loading pipeline. If filenames contain timestamps, the offset is now auto-calculated.
- src/sync.js: Simplified the `videoFrameCallback` loop. It now treats the video as the "Master Clock" and looks up the pre-computed synced time, ensuring 60fps performance during playback and scrubbing.
- UI: Manual offset adjustments now trigger a "re-bake" of the entire timeline instantly.
refactor/sync-centralize
RUSHIL AMBARISH KADU 6 months ago
parent
commit
7690b899f5
  1. 4
      steps/src/debug.js
  2. 52
      steps/src/main.js
  3. 22
      steps/src/sync.js
  4. 43
      steps/src/utils.js

4
steps/src/debug.js

@ -5,10 +5,10 @@
export const debugFlags = { export const debugFlags = {
// Logs from videoFrameCallback and animationLoop in sync.js // Logs from videoFrameCallback and animationLoop in sync.js
sync: true,
sync: false,
// Logs from the main p5.js draw() functions (e.g., radarSketch.js) // Logs from the main p5.js draw() functions (e.g., radarSketch.js)
drawing: true,
drawing: false,
// Logs related to file loading, parsing, and caching // Logs related to file loading, parsing, and caching
fileLoading: false, fileLoading: false,

52
steps/src/main.js

@ -51,6 +51,7 @@ import {
findRadarFrameIndexForTime, findRadarFrameIndexForTime,
extractTimestampInfo, extractTimestampInfo,
parseTimestamp, parseTimestamp,
precomputeRadarVideoSync,
throttle, throttle,
formatTime, formatTime,
} from "./utils.js"; } from "./utils.js";
@ -216,6 +217,7 @@ async function processFilePipeline() {
appState.vizData = result.data; appState.vizData = result.data;
appState.globalMinSnr = result.minSnr; appState.globalMinSnr = result.minSnr;
appState.globalMaxSnr = result.maxSnr; appState.globalMaxSnr = result.maxSnr;
precomputeRadarVideoSync(appState.vizData, appState.offset);
} }
// 3. Handle Video Loading SECOND, with two-stage initialization // 3. Handle Video Loading SECOND, with two-stage initialization
@ -317,16 +319,6 @@ function finalizeSetup(_parsedJsonData) {
canvasPlaceholder.style.display = "none"; canvasPlaceholder.style.display = "none";
featureToggles.classList.remove("hidden"); featureToggles.classList.remove("hidden");
// This is the critical step. We loop through the radar data ONCE to create
// a relative timestamp in seconds for every frame. This simplifies all
// future synchronization math.
if (appState.vizData) {
appState.vizData.radarFrames.forEach((frame) => {
// frame.timestamp is the relative time in ms from the radar's start.
// We convert it to seconds for easier comparison with video.mediaTime.
frame.relativeTimeSec = frame.timestamp / 1000;
});
}
// Create the p5 instances // Create the p5 instances
if (!appState.p5_instance) { if (!appState.p5_instance) {
@ -855,32 +847,47 @@ document.addEventListener("keydown", (event) => {
function calculateAndSetOffset() { function calculateAndSetOffset() {
const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename); const jsonTimestampInfo = extractTimestampInfo(appState.jsonFilename);
const videoTimestampInfo = extractTimestampInfo(appState.videoFilename); const videoTimestampInfo = extractTimestampInfo(appState.videoFilename);
let videoDate = null;
if (videoTimestampInfo) { if (videoTimestampInfo) {
appState.videoStartDate = parseTimestamp(
videoDate = parseTimestamp(
videoTimestampInfo.timestampStr, videoTimestampInfo.timestampStr,
videoTimestampInfo.format videoTimestampInfo.format
); );
if (appState.videoStartDate) {
}
appState.videoStartDate = videoDate; // Store for potential future use
} }
let jsonDate = null;
if (jsonTimestampInfo) { if (jsonTimestampInfo) {
const jsonDate = parseTimestamp(
jsonDate = parseTimestamp(
jsonTimestampInfo.timestampStr, jsonTimestampInfo.timestampStr,
jsonTimestampInfo.format jsonTimestampInfo.format
); );
if (jsonDate) {
}
let calculatedOffset = 0;
if (jsonDate && videoDate) {
appState.radarStartTimeMs = jsonDate.getTime(); appState.radarStartTimeMs = jsonDate.getTime();
console.log(`Radar start date set to: ${jsonDate.toISOString()}`);
if (appState.videoStartDate) {
appState.offset =
appState.radarStartTimeMs - appState.videoStartDate.getTime();
offsetInput.value = appState.offset;
localStorage.setItem("visualizerOffset", appState.offset);
const offset = jsonDate.getTime() - videoDate.getTime();
// Logic Rule: If offset is invalid or too large, default to 0.
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;
} else {
calculatedOffset = offset;
autoOffsetIndicator.classList.remove("hidden"); autoOffsetIndicator.classList.remove("hidden");
console.log(`Auto-calculated offset: ${appState.offset} ms`);
console.log(`Auto-calculated offset: ${calculatedOffset} ms`);
} }
} }
appState.offset = calculatedOffset;
offsetInput.value = appState.offset;
localStorage.setItem("visualizerOffset", appState.offset);
// Trigger Baking: This is the point where we apply the offset to the data.
if (appState.vizData) {
precomputeRadarVideoSync(appState.vizData, appState.offset);
} }
} }
offsetInput.addEventListener("keydown", (event) => { offsetInput.addEventListener("keydown", (event) => {
@ -893,6 +900,7 @@ offsetInput.addEventListener("keydown", (event) => {
const newOffset = parseFloat(offsetInput.value) || 0; const newOffset = parseFloat(offsetInput.value) || 0;
appState.offset = newOffset; appState.offset = newOffset;
localStorage.setItem("visualizerOffset", newOffset); localStorage.setItem("visualizerOffset", newOffset);
if (appState.vizData) precomputeRadarVideoSync(appState.vizData, appState.offset);
console.log(`Manual offset entered: ${appState.offset}ms`); console.log(`Manual offset entered: ${appState.offset}ms`);
// Force a resync of the video to the current frame // Force a resync of the video to the current frame

22
steps/src/sync.js

@ -12,7 +12,7 @@ import {
egoSpeedDisplay, egoSpeedDisplay,
canSpeedDisplay, canSpeedDisplay,
} from "./dom.js"; } from "./dom.js";
import { findRadarFrameIndexForTime } from "./utils.js";
import { findRadarFrameIndexForTime, precomputeRadarVideoSync } from "./utils.js";
import { throttledUpdateExplorer } from "./dataExplorer.js"; import { throttledUpdateExplorer } from "./dataExplorer.js";
import { debugFlags } from "./debug.js"; import { debugFlags } from "./debug.js";
@ -52,6 +52,9 @@ export function forceResyncWithOffset() {
appState.offset = newOffset; // Update the central state appState.offset = newOffset; // Update the central state
localStorage.setItem("visualizerOffset", newOffset); // Persist it localStorage.setItem("visualizerOffset", newOffset); // Persist it
// Re-Bake: Overwrite the pre-calculated sync times with the new offset.
precomputeRadarVideoSync(appState.vizData, appState.offset);
console.log(`Forcing resync with new offset: ${appState.offset}ms`); console.log(`Forcing resync with new offset: ${appState.offset}ms`);
// If the video is playing, pause it to allow for precise frame tuning. // If the video is playing, pause it to allow for precise frame tuning.
@ -115,11 +118,9 @@ export function updateFrame(frame, forceVideoSeek = false) {
frameData frameData
) { ) {
// Convert frame's relative time to the video's timeline // Convert frame's relative time to the video's timeline
const targetRadarTimeSec = frameData.relativeTimeSec;
const targetVideoTimeSec = targetRadarTimeSec + (appState.offset / 1000);
const targetVideoTimeSec = frameData.videoSyncedTime;
if (targetVideoTimeSec >= 0 && targetVideoTimeSec <= videoPlayer.duration) {
if (targetVideoTimeSec >= 0 && videoPlayer.duration && targetVideoTimeSec <= videoPlayer.duration) {
// Ensure target time is within video duration // Ensure target time is within video duration
if (Math.abs(videoPlayer.currentTime - targetVideoTimeSec) > 0.05) { if (Math.abs(videoPlayer.currentTime - targetVideoTimeSec) > 0.05) {
// Check for significant drift // Check for significant drift
@ -168,14 +169,13 @@ export function videoFrameCallback(now, metadata) {
return; return;
} }
// 1. Get video time and calculate the target time on the radar's timeline.
const videoNowSec = metadata.mediaTime;
const targetRadarTimeSec = videoNowSec - (appState.offset / 1000);
// 1. Get the video's current time directly from the callback metadata.
const videoCurrentTime = metadata.mediaTime;
// 2. Find the corresponding radar frame index. // 2. Find the corresponding radar frame index.
const frameIndex = findRadarFrameIndexForTime(targetRadarTimeSec, appState.vizData);
const frameIndex = findRadarFrameIndexForTime(videoCurrentTime, appState.vizData);
// 3. Update the application state. This is the ONLY state this function changes.
// 3. Update the application state if the frame has changed.
if (frameIndex !== appState.currentFrame) { if (frameIndex !== appState.currentFrame) {
appState.currentFrame = frameIndex; appState.currentFrame = frameIndex;
// This is the ONLY state this function should change. All UI updates are in animationLoop. // This is the ONLY state this function should change. All UI updates are in animationLoop.
@ -264,7 +264,7 @@ function handleTimelineWheel(event) {
// 4. Calculate the new frame index. // 4. Calculate the new frame index.
const direction = Math.sign(event.deltaY); const direction = Math.sign(event.deltaY);
// FIX: Invert the direction. Scrolling down (positive deltaY) should advance the frame. // FIX: Invert the direction. Scrolling down (positive deltaY) should advance the frame.
let newFrame = appState.currentFrame + direction * seekAmount;
let newFrame = appState.currentFrame - direction * seekAmount;
// 5. Clamp the new frame to the valid range. // 5. Clamp the new frame to the valid range.
const totalFrames = appState.vizData.radarFrames.length - 1; const totalFrames = appState.vizData.radarFrames.length - 1;

43
steps/src/utils.js

@ -1,27 +1,35 @@
/**
* Performs a binary search on the radar frames to find the frame index
* closest to the target video time.
*
* @param {number} targetTimeSec - The target time in seconds (from video.currentTime).
* @param {object} vizData - The visualization data containing radarFrames.
* @returns {number} The index of the closest radar frame.
*/
export function findRadarFrameIndexForTime(targetTimeSec, vizData) { export function findRadarFrameIndexForTime(targetTimeSec, vizData) {
if (!vizData || vizData.radarFrames.length === 0) return -1; if (!vizData || vizData.radarFrames.length === 0) return -1;
// Initialize low, high, and answer variables for binary search // Initialize low, high, and answer variables for binary search
// 'ans' will store the index of the closest frame found so far // 'ans' will store the index of the closest frame found so far
// 'low' and 'high' define the search range // 'low' and 'high' define the search range
let low = 0, let low = 0,
high = vizData.radarFrames.length - 1,
ans = 0;
high = vizData.radarFrames.length - 1;
// Perform binary search to find the radar frame whose timestamp is closest to, but not exceeding, the target time // Perform binary search to find the radar frame whose timestamp is closest to, but not exceeding, the target time
while (low <= high) { while (low <= high) {
let mid = Math.floor((low + high) / 2); let mid = Math.floor((low + high) / 2);
// If the current frame's timestamp is less than or equal to the target time,
// it's a potential answer, and we try to find a more recent one in the right half.
if (vizData.radarFrames[mid].relativeTimeSec <= targetTimeSec) {
ans = mid;
const frameTime = vizData.radarFrames[mid].videoSyncedTime;
if (frameTime < targetTimeSec) {
low = mid + 1; low = mid + 1;
} else {
// If the current frame's timestamp is greater than the target time,
// we need to look in the left half.
} else if (frameTime > targetTimeSec) {
high = mid - 1; high = mid - 1;
} else {
// Exact match found
return mid;
} }
} }
// Return the index of the found radar frame.
return ans;
// No exact match, return the closest index (clamped to bounds)
return Math.max(0, Math.min(high, vizData.radarFrames.length - 1));
} }
@ -144,3 +152,16 @@ export function formatUTCTime(date) {
const milliseconds = String(date.getUTCMilliseconds()).padStart(3, '0'); const milliseconds = String(date.getUTCMilliseconds()).padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${milliseconds}`; return `${hours}:${minutes}:${seconds}.${milliseconds}`;
} }
/**
* Pre-calculates the video-synchronized timestamp for each radar frame.
* This "bakes" the offset into the data, simplifying future sync calculations.
*
* @param {object} vizData - The visualization data containing radarFrames.
* @param {number} offsetMs - The time offset between radar and video in milliseconds.
*/
export function precomputeRadarVideoSync(vizData, offsetMs) {
vizData.radarFrames.forEach((frame) => {
frame.videoSyncedTime = (frame.timestamp + offsetMs) / 1000;
});
}
Loading…
Cancel
Save