BetaHub Video Events Sync
July 3, 2025Advanced package for precise synchronization of time-based events (logs, interactions, errors) with video playback.
The project was created in mid-2025 as a response to specific needs of the BetaHub platform in the area of video game testing. It's an advanced npm package that enables precise synchronization of time-based events (logs, interactions, errors) with video playback.
The problem that the library solves is characteristic of the gamedev industry: during game testing, logs are generated in real-time while gameplay is being recorded. Later, during video analysis, it's crucial to display relevant logs at the exact moments when they occurred during the game.
This is a computational library, consciously designed without user interface elements. This approach offers complete freedom in frontend implementation - developers can integrate it with any UI framework. The library provides only time synchronization algorithms and a callback system, leaving visualization in the hands of the programmer.
Below is an example interface implementation:
The library handles asynchronous loading and processing of files in JSONL (JSON Lines) format, where each line represents a single event. The loading process is fully asynchronous with a callback system for monitoring progress:
typescriptconst bhves = new BHVESInstance({ videoPlayerDomId: 'video-player', startTimestamp: '2025-06-12T14:03:20', onStateUpdate: ({ state, data }) => { // Update interface when state changes } }); await bhves.addData({ entries: [ { name: 'logs', dataJSONL: '{"start_timestamp":"2025-06-12T14:03:25","type":"error","message":"Collision detection error"}\n{"start_timestamp":"2025-06-12T14:03:30","type":"info","message":"Player collected item"}' } ], options: { sortData: true, onProgress: (status) => { // Monitor processing progress }, onSuccess: (loadedData) => { // Data successfully loaded and indexed }, onError: (error) => { // Handle processing errors } } });const bhves = new BHVESInstance({ videoPlayerDomId: 'video-player', startTimestamp: '2025-06-12T14:03:20', onStateUpdate: ({ state, data }) => { // Update interface when state changes } }); await bhves.addData({ entries: [ { name: 'logs', dataJSONL: '{"start_timestamp":"2025-06-12T14:03:25","type":"error","message":"Collision detection error"}\n{"start_timestamp":"2025-06-12T14:03:30","type":"info","message":"Player collected item"}' } ], options: { sortData: true, onProgress: (status) => { // Monitor processing progress }, onSuccess: (loadedData) => { // Data successfully loaded and indexed }, onError: (error) => { // Handle processing errors } } });
onProgress
: Called during processing with progress information (0-100%)onSuccess
: Called when all data has been successfully loaded and indexedonError
: Called in case of parsing, validation, or indexing errors
The library uses two main callbacks:
onTimeUpdate
: Called on every video time changeonStateUpdate
: Called when the set of active events changes
Automatic connection to any HTML5 <video>
element:
typescript// Automatic connection by ID const bhves = new BHVESInstance({ videoPlayerDomId: 'my-video-player', // ... });// Automatic connection by ID const bhves = new BHVESInstance({ videoPlayerDomId: 'my-video-player', // ... });
Ability to create and manage multiple independent instances for different players on the same page.
The choice of TypeScript was strategic for several reasons:
- Type Safety: Complex API with multiple configuration options requires a strong type system
- Developer Experience: IntelliSense and validation while writing code for teams using the library
- Maintainability: Facilitated refactoring and development in the team
The library uses microbundle
for building, which provides:
- Small build size
- TypeScript definitions: Automatic generation of
.d.ts
files
The conscious decision to create a computational library without UI means:
- Freedom in UI implementation: Works with React, Vue, Angular or vanilla JavaScript
- Separation of concerns: Synchronization algorithms separated from presentation layer
- Easier testing: Core logic can be tested independently of the interface
The system was designed following the "separation of concerns" pattern, where each component has a clearly defined responsibility.
The main class that manages the entire system:
- Initialization: Connects to video element and configures callbacks
- Event handling: Listens to
timeupdate
events from the player - Time synchronization: Converts video time to timestamps
- Communication: Calls user callbacks with current data
Central repository for all data. Stores data in two formats.
typescriptprivate data: { [key: string]: Data[] } = {}; // Original format private dataInternal: { [key: string]: DataInternal[] } = {}; // Optimized format for searchingprivate data: { [key: string]: Data[] } = {}; // Original format private dataInternal: { [key: string]: DataInternal[] } = {}; // Optimized format for searching
- Dual data format: Original data for user, parsed for performance
- One-time conversion: Timestamps parsed once during loading
Indexing system based on binary search:
- Sorted indexes: Automatic sorting of events by timestamps
- Binary search: O(log n) complexity for searching in large datasets
The diagram shows relationships between main components:
- BHVESInstance as API entry point
- DataStore as central data repository
- DataIndexManager as search engine
- Utility functions as helper functions for the user
The diagram shows data flow during typical lifecycle:
- Initialization: Instance creation and video connection
- Data loading: JSONL processing and index building
- Runtime: Continuous synchronization during playback
- Callbacks: State change notifications
typescriptconst videoPlayerTimeMilliseconds = videoPlayerTimeSeconds * 1000; const absoluteTimestampMs = this.startTimestampParsed.getTime() + videoPlayerTimeMilliseconds; const currentTimeDate = new Date(absoluteTimestampMs); // Finding matching events const matchingIndexes = this.dataStore.findMatchingIndexes(currentTimeDate);const videoPlayerTimeMilliseconds = videoPlayerTimeSeconds * 1000; const absoluteTimestampMs = this.startTimestampParsed.getTime() + videoPlayerTimeMilliseconds; const currentTimeDate = new Date(absoluteTimestampMs); // Finding matching events const matchingIndexes = this.dataStore.findMatchingIndexes(currentTimeDate);
The system operates in real-time, converting relative video player time to absolute timestamp, then finding all events that should be active at that moment.
typescriptimport { BHVESInstance } from 'betahub-video-events-sync'; const bhves = new BHVESInstance({ videoPlayerDomId: 'main-video-player', startTimestamp: '2025-06-12T14:03:20', // Recording start moment onTimeUpdate: ({ videoPlayerTimeSeconds, timestamp }) => { // Update system time of logs in interface document.getElementById('system-time').textContent = timestamp; }, onStateUpdate: ({ state, data }) => { // React to changes in active events updateEventDisplay(state, data); } });import { BHVESInstance } from 'betahub-video-events-sync'; const bhves = new BHVESInstance({ videoPlayerDomId: 'main-video-player', startTimestamp: '2025-06-12T14:03:20', // Recording start moment onTimeUpdate: ({ videoPlayerTimeSeconds, timestamp }) => { // Update system time of logs in interface document.getElementById('system-time').textContent = timestamp; }, onStateUpdate: ({ state, data }) => { // React to changes in active events updateEventDisplay(state, data); } });
typescriptawait bhves.addData({ entries: [ { name: 'game_logs', dataJSONL: `{"start_timestamp":"2025-06-12T14:03:25","type":"error","message":"NullReferenceException in PlayerController"} {"start_timestamp":"2025-06-12T14:03:30","type":"info","message":"Player entered new area"} {"start_timestamp":"2025-06-12T14:03:35","type":"warning","message":"FPS dropped below 30"}` }, { name: 'user_interactions', dataJSONL: `{"start_timestamp":"2025-06-12T14:03:22","end_timestamp":"2025-06-12T14:03:24","type":"click","message":"Attack button click"} {"start_timestamp":"2025-06-12T14:03:28","end_timestamp":"2025-06-12T14:03:32","type":"drag","message":"Dragging item to inventory"}` } ], options: { sortData: true, onProgress: (status) => { const progressBar = document.getElementById('progress'); progressBar.style.width = `${status.progress}%`; if (status.status === 'loading') { console.log(`Loading: ${status.progress}%`); } }, onSuccess: (loadedData) => { console.log('Loaded categories:', Object.keys(loadedData)); showNotification('Data loaded successfully!'); }, onError: (error) => { console.error('Loading error:', error.message); showErrorDialog(error.message); } } });await bhves.addData({ entries: [ { name: 'game_logs', dataJSONL: `{"start_timestamp":"2025-06-12T14:03:25","type":"error","message":"NullReferenceException in PlayerController"} {"start_timestamp":"2025-06-12T14:03:30","type":"info","message":"Player entered new area"} {"start_timestamp":"2025-06-12T14:03:35","type":"warning","message":"FPS dropped below 30"}` }, { name: 'user_interactions', dataJSONL: `{"start_timestamp":"2025-06-12T14:03:22","end_timestamp":"2025-06-12T14:03:24","type":"click","message":"Attack button click"} {"start_timestamp":"2025-06-12T14:03:28","end_timestamp":"2025-06-12T14:03:32","type":"drag","message":"Dragging item to inventory"}` } ], options: { sortData: true, onProgress: (status) => { const progressBar = document.getElementById('progress'); progressBar.style.width = `${status.progress}%`; if (status.status === 'loading') { console.log(`Loading: ${status.progress}%`); } }, onSuccess: (loadedData) => { console.log('Loaded categories:', Object.keys(loadedData)); showNotification('Data loaded successfully!'); }, onError: (error) => { console.error('Loading error:', error.message); showErrorDialog(error.message); } } });
typescriptimport { getMatchingData, getMovingWindowIndexes, getShiftedIndexes, getSecondsFromTimestamp } from 'betahub-video-events-sync'; function updateEventDisplay(state, data) { // 1. Get currently active events const activeEvents = getMatchingData(state.activeMatchingIndexes, data); // 2. Create "moving window" - show context const { prepend, append } = getMovingWindowIndexes( state.activeMatchingIndexes, data, { prependSize: 3, // 3 previous appendSize: 5, // 5 next minimumSize: 10 // minimum 10 together } ); const previousEvents = getMatchingData(prepend, data); const upcomingEvents = getMatchingData(append, data); // 3. Display in interface renderEventsList('active', activeEvents); renderEventsList('previous', previousEvents, { opacity: 0.5 }); renderEventsList('upcoming', upcomingEvents, { opacity: 0.7 }); } // Navigation to specific moment function jumpToEvent(eventTimestamp) { const videoTime = getSecondsFromTimestamp( '2025-06-12T14:03:20', // recording start eventTimestamp // event moment ); document.getElementById('video-player').currentTime = videoTime; }import { getMatchingData, getMovingWindowIndexes, getShiftedIndexes, getSecondsFromTimestamp } from 'betahub-video-events-sync'; function updateEventDisplay(state, data) { // 1. Get currently active events const activeEvents = getMatchingData(state.activeMatchingIndexes, data); // 2. Create "moving window" - show context const { prepend, append } = getMovingWindowIndexes( state.activeMatchingIndexes, data, { prependSize: 3, // 3 previous appendSize: 5, // 5 next minimumSize: 10 // minimum 10 together } ); const previousEvents = getMatchingData(prepend, data); const upcomingEvents = getMatchingData(append, data); // 3. Display in interface renderEventsList('active', activeEvents); renderEventsList('previous', previousEvents, { opacity: 0.5 }); renderEventsList('upcoming', upcomingEvents, { opacity: 0.7 }); } // Navigation to specific moment function jumpToEvent(eventTimestamp) { const videoTime = getSecondsFromTimestamp( '2025-06-12T14:03:20', // recording start eventTimestamp // event moment ); document.getElementById('video-player').currentTime = videoTime; }
typescript// Handling two players - main and comparison const mainRecording = new BHVESInstance({ videoPlayerDomId: 'main-video', startTimestamp: '2025-06-12T14:03:20', onStateUpdate: ({ state, data }) => { updateMainTimeline(state, data); } }); const comparisonRecording = new BHVESInstance({ videoPlayerDomId: 'comparison-video', startTimestamp: '2025-06-12T15:30:00', // Different recording onStateUpdate: ({ state, data }) => { updateComparisonTimeline(state, data); } }); // Loading different data sets await Promise.all([ mainRecording.addData({ entries: [{ name: 'session_1_logs', dataJSONL: session1Data }] }), comparisonRecording.addData({ entries: [{ name: 'session_2_logs', dataJSONL: session2Data }] }) ]);// Handling two players - main and comparison const mainRecording = new BHVESInstance({ videoPlayerDomId: 'main-video', startTimestamp: '2025-06-12T14:03:20', onStateUpdate: ({ state, data }) => { updateMainTimeline(state, data); } }); const comparisonRecording = new BHVESInstance({ videoPlayerDomId: 'comparison-video', startTimestamp: '2025-06-12T15:30:00', // Different recording onStateUpdate: ({ state, data }) => { updateComparisonTimeline(state, data); } }); // Loading different data sets await Promise.all([ mainRecording.addData({ entries: [{ name: 'session_1_logs', dataJSONL: session1Data }] }), comparisonRecording.addData({ entries: [{ name: 'session_2_logs', dataJSONL: session2Data }] }) ]);
The library uses JSONL (JSON Lines) format, where each line is a separate event in JSON format. This format was chosen due to:
- Parsing efficiency: Each line can be processed independently
- Compatibility: Standard format used in logs and data streaming
- Readability: Easy to debug and manually edit
typescriptinterface Data { start_timestamp: string; // Event start timestamp end_timestamp?: string; // Optional end timestamp type: string; // Event type (log, interaction, error, any other) message?: string; // Message content details?: object; // Additional metadata }interface Data { start_timestamp: string; // Event start timestamp end_timestamp?: string; // Optional end timestamp type: string; // Event type (log, interaction, error, any other) message?: string; // Message content details?: object; // Additional metadata }
json{"start_timestamp":"2025-06-12T14:03:20","type":"game_start","message":"Test session start","details":{"level":"tutorial","tester_id":"user_123"}} {"start_timestamp":"2025-06-12T14:03:25","type":"error","message":"Texture loading failed","details":{"asset":"player_texture.png","error_code":"404"}} {"start_timestamp":"2025-06-12T14:03:22","end_timestamp":"2025-06-12T14:03:28","type":"interaction","message":"Dialogue window","details":{"npc_name":"Village Elder","choices_shown":3}}{"start_timestamp":"2025-06-12T14:03:20","type":"game_start","message":"Test session start","details":{"level":"tutorial","tester_id":"user_123"}} {"start_timestamp":"2025-06-12T14:03:25","type":"error","message":"Texture loading failed","details":{"asset":"player_texture.png","error_code":"404"}} {"start_timestamp":"2025-06-12T14:03:22","end_timestamp":"2025-06-12T14:03:28","type":"interaction","message":"Dialogue window","details":{"npc_name":"Village Elder","choices_shown":3}}
start_timestamp
: Required - event start timestampend_timestamp
: Optional - for events lasting over timetype
: Required - event categorizationmessage
: Optional - readable descriptiondetails
: Optional - additional metadata in object format
Problem: Event synchronization with millisecond precision was critical for user experience. Differences of even 100-200ms would be noticeable during test video analysis. Often videos are analyzed frame by frame.
Solution:
- Using ISO 8601 format for all timestamps
- Parsing all timestamps during data loading
Problem: Test sessions can generate millions of events. Linear search in real-time would have high performance impact.
Solution:
- Advanced indexing system in
DataIndexManager
- One-time processing of all timestamps during data loading
- Binary search instead of linear for efficient data access
Processing Optimizations:
- Chunked processing (10,000 records per chunk) with
setTimeout(0)
to avoid blocking UI - Parallel processing: ability to load multiple files simultaneously
- Optional data sorting during loading
- Dual storage: original data for API, optimized for searching
Problem: The library must be useful with React, Vue, Angular and vanilla JS, without imposing specific implementation style.
Solution:
- Callback-based architecture offering both maximum flexibility and ease of use
- State system containing current player time, current timestamp, and event indexes
typescriptinterface State { videoPlayerTimeSeconds: number; timestamp: string; matchingIndexes: CategoryIndexes; // All active events activeMatchingIndexes: CategoryIndexes; // Latest from each category }interface State { videoPlayerTimeSeconds: number; timestamp: string; matchingIndexes: CategoryIndexes; // All active events activeMatchingIndexes: CategoryIndexes; // Latest from each category }
Key Design Decisions:
- Separation of
matchingIndexes
vsactiveMatchingIndexes
for different use cases - Helper functions instead of UI components
- No external dependencies for maximum compatibility
BetaHub Video Events Sync is a specialized library that solves a real problem in the game development industry - precise synchronization of time-based events with gameplay recordings. Its creation was driven by specific needs of the BetaHub platform in the area of game test analysis.
This project was a fascinating experience in library design.
It confirmed my belief that the best projects arise from solving real, challenging business problems - not from abstract technical assumptions.