logo
Hello World!

BetaHub Video Events Sync

3 lipca 2025
typescriptbetahubpackage

Zaawansowana paczka do precyzyjnej synchronizacji wydarzeń czasowych (logów, interakcji, błędów) z odtwarzaniem wideo.

Projekt powstał w połowie 2025 roku jako odpowiedź na konkretne potrzeby platformy BetaHub w obszarze testowania gier wideo. To zaawansowana paczka npm, pozwalająca na precyzyjną synchronizację wydarzeń czasowych (logów, interakcji, błędów) z odtwarzaniem nagrań wideo.

Problem, który rozwiązuje biblioteka, jest charakterystyczny dla branży gamedev: podczas testowania gier generowane są logi w czasie rzeczywistym, a jednocześnie nagrywany jest gameplay. Później, podczas analizy nagrania, kluczowe jest wyświetlenie odpowiednich logów w dokładnych momentach, w których wystąpiły podczas gry.

Jest to biblioteka obliczeniowa, świadomie zaprojektowana bez elementów interfejsu użytkownika. To podejście oferuje pełną dowolność w sposobie implementacji frontendu - deweloperzy mogą zintegrować ją z dowolnym frameworkiem UI. Biblioteka dostarcza jedynie algorytmy synchronizacji czasowej i system callbacków, pozostawiając wizualizację w rękach programisty.

Poniżej przykład implementacji interfejsu:



Biblioteka obsługuje asynchroniczne ładowanie i przetwarzanie plików w formacie JSONL (JSON Lines), gdzie każda linia reprezentuje jedno wydarzenie. Proces ładowania jest w pełni asynchroniczny z systemem callbacków dla monitorowania postępu:

typescript
const bhves = new BHVESInstance({
  videoPlayerDomId: 'video-player',
  startTimestamp: '2025-06-12T14:03:20',
  onStateUpdate: ({ state, data }) => {
    // Aktualizacja interfejsu gdy zmieni się stan
  }
});

await bhves.addData({
  entries: [
    { 
      name: 'logs', 
      dataJSONL: '{"start_timestamp":"2025-06-12T14:03:25","type":"error","message":"Błąd collision detection"}\n{"start_timestamp":"2025-06-12T14:03:30","type":"info","message":"Gracz zebrał przedmiot"}'
    }
  ],
  options: {
    sortData: true,
    onProgress: (status) => {
      // Monitorowanie postępu przetwarzania
    },
    onSuccess: (loadedData) => {
      // Dane zostały pomyślnie załadowane i zindeksowane
    },
    onError: (error) => {
      // Obsługa błędów podczas przetwarzania
    }
  }
});
const bhves = new BHVESInstance({
  videoPlayerDomId: 'video-player',
  startTimestamp: '2025-06-12T14:03:20',
  onStateUpdate: ({ state, data }) => {
    // Aktualizacja interfejsu gdy zmieni się stan
  }
});

await bhves.addData({
  entries: [
    { 
      name: 'logs', 
      dataJSONL: '{"start_timestamp":"2025-06-12T14:03:25","type":"error","message":"Błąd collision detection"}\n{"start_timestamp":"2025-06-12T14:03:30","type":"info","message":"Gracz zebrał przedmiot"}'
    }
  ],
  options: {
    sortData: true,
    onProgress: (status) => {
      // Monitorowanie postępu przetwarzania
    },
    onSuccess: (loadedData) => {
      // Dane zostały pomyślnie załadowane i zindeksowane
    },
    onError: (error) => {
      // Obsługa błędów podczas przetwarzania
    }
  }
});

  • onProgress: Wywoływany podczas przetwarzania z informacją o postępie (0-100%)
  • onSuccess: Wywoływany gdy wszystkie dane zostały pomyślnie załadowane i zindeksowane
  • onError: Wywoływany w przypadku błędów parsowania, walidacji lub indeksowania

Biblioteka używa dwóch głównych callbacków:

  • onTimeUpdate: Wywoływany przy każdej zmianie czasu wideo
  • onStateUpdate: Wywoływany gdy zmienia się zestaw aktywnych wydarzeń

Automatyczne podłączenie do dowolnego elementu <video> HTML5:

typescript
// Automatyczne podłączenie przez ID
const bhves = new BHVESInstance({
  videoPlayerDomId: 'my-video-player',
  // ...
});
// Automatyczne podłączenie przez ID
const bhves = new BHVESInstance({
  videoPlayerDomId: 'my-video-player',
  // ...
});

Możliwość tworzenia i zarządzania wieloma niezależnymi instancjami dla różnych odtwarzaczy na tej samej stronie.

Wybór TypeScript był strategiczny z kilku powodów:

  • Bezpieczeństwo typów: Kompleksne API z wieloma opcjami konfiguracji wymaga silnego systemu typów
  • Doświadczenie deweloperskie: IntelliSense i walidacja w czasie pisania kodu dla zespołów używających biblioteki
  • Łatwość utrzymania: Ułatwiona refaktoryzacja i rozwój w zespole

Biblioteka używa microbundle do budowania, co zapewnia:

  • Mały rozmiar builda
  • Definicje TypeScript: Automatyczne generowanie plików .d.ts

Świadoma decyzja o stworzeniu biblioteki obliczeniowej bez UI oznacza:

  • Dowolność w implementacji UI: Działa z React, Vue, Angular czy vanilla JavaScript
  • Separacja odpowiedzialności: Algorytmy synchronizacji oddzielone od warstwy prezentacji
  • Łatwiejsze testowanie: Logika główna może być testowana niezależnie od interfejsu

System został zaprojektowany według wzorca "separation of concerns", gdzie każdy komponent ma jasno określoną odpowiedzialność.

Główna klasa, która zarządza całym systemem:

  • Inicjalizacja: Łączy się z elementem video i konfiguruje callbacki
  • Obsługa zdarzeń: Nasłuchuje eventów timeupdate z odtwarzacza
  • Synchronizacja czasowa: Konwertuje czas wideo na timestampy
  • Komunikacja: Wywołuje callbacki użytkownika z aktualnymi danymi

Centralne repozytorium dla wszystkich danych. Przechowuje dane w dwóch formatach.

typescript
private data: { [key: string]: Data[] } = {};           // Format oryginalny
private dataInternal: { [key: string]: DataInternal[] } = {}; // Format zoptymalizowany pod wyszukiwanie
private data: { [key: string]: Data[] } = {};           // Format oryginalny
private dataInternal: { [key: string]: DataInternal[] } = {}; // Format zoptymalizowany pod wyszukiwanie
  • Podwójny format danych: Oryginalne dane dla użytkownika, sparsowane dla wydajności
  • Jednorazowa konwersja: Timestamps parsowane jednorazowo przy ładowaniu

System indeksowania oparty na wyszukiwaniu binarnym:

  • Posortowane indeksy: Automatyczne sortowanie wydarzeń po znacznikach czasu
  • Wyszukiwanie binarne: Złożoność O(log n) dla wyszukiwania w dużych zbiorach

Class Diagram

Diagram przedstawia relacje między głównymi komponentami:

  • BHVESInstance jako punkt wejścia API
  • DataStore jako centralne repozytorium danych
  • DataIndexManager jako silnik wyszukiwania
  • Utility functions jako pomocnicze funkcje dla użytkownika

Sequence Diagram

Diagram pokazuje przepływ danych podczas typowego cyklu życia:

  1. Inicjalizacja: Utworzenie instancji i podłączenie do video
  2. Ładowanie danych: Przetwarzanie JSONL i budowanie indeksów
  3. Runtime: Ciągła synchronizacja podczas odtwarzania
  4. Callbacks: Powiadamianie o zmianach stanu

typescript
const videoPlayerTimeMilliseconds = videoPlayerTimeSeconds * 1000;
const absoluteTimestampMs = this.startTimestampParsed.getTime() + videoPlayerTimeMilliseconds;
const currentTimeDate = new Date(absoluteTimestampMs);

// Wyszukiwanie pasujących wydarzeń
const matchingIndexes = this.dataStore.findMatchingIndexes(currentTimeDate);
const videoPlayerTimeMilliseconds = videoPlayerTimeSeconds * 1000;
const absoluteTimestampMs = this.startTimestampParsed.getTime() + videoPlayerTimeMilliseconds;
const currentTimeDate = new Date(absoluteTimestampMs);

// Wyszukiwanie pasujących wydarzeń
const matchingIndexes = this.dataStore.findMatchingIndexes(currentTimeDate);

System działa w czasie rzeczywistym, konwertując relatywny czas odtwarzacza wideo na absolutny timestamp, a następnie znajduje wszystkie wydarzenia, które powinny być aktywne w tym momencie.

typescript
import { BHVESInstance } from 'betahub-video-events-sync';

const bhves = new BHVESInstance({
  videoPlayerDomId: 'main-video-player',
  startTimestamp: '2025-06-12T14:03:20', // Moment startu nagrania
  onTimeUpdate: ({ videoPlayerTimeSeconds, timestamp }) => {
    // Aktualizacja czasu systemowego logów w interfejsie
    document.getElementById('system-time').textContent = timestamp;
  },
  onStateUpdate: ({ state, data }) => {
    // Reakcja na zmiany aktywnych wydarzeń
    updateEventDisplay(state, data);
  }
});
import { BHVESInstance } from 'betahub-video-events-sync';

const bhves = new BHVESInstance({
  videoPlayerDomId: 'main-video-player',
  startTimestamp: '2025-06-12T14:03:20', // Moment startu nagrania
  onTimeUpdate: ({ videoPlayerTimeSeconds, timestamp }) => {
    // Aktualizacja czasu systemowego logów w interfejsie
    document.getElementById('system-time').textContent = timestamp;
  },
  onStateUpdate: ({ state, data }) => {
    // Reakcja na zmiany aktywnych wydarzeń
    updateEventDisplay(state, data);
  }
});

typescript
await bhves.addData({
  entries: [
    {
      name: 'game_logs',
      dataJSONL: `{"start_timestamp":"2025-06-12T14:03:25","type":"error","message":"NullReferenceException w PlayerController"}
{"start_timestamp":"2025-06-12T14:03:30","type":"info","message":"Gracz wszedł do nowego obszaru"}
{"start_timestamp":"2025-06-12T14:03:35","type":"warning","message":"FPS spadł poniżej 30"}`
    },
    {
      name: 'user_interactions', 
      dataJSONL: `{"start_timestamp":"2025-06-12T14:03:22","end_timestamp":"2025-06-12T14:03:24","type":"click","message":"Kliknięcie przycisku Attack"}
{"start_timestamp":"2025-06-12T14:03:28","end_timestamp":"2025-06-12T14:03:32","type":"drag","message":"Przeciągnięcie przedmiotu do inventory"}`
    }
  ],
  options: {
    sortData: true,
    onProgress: (status) => {
      const progressBar = document.getElementById('progress');
      progressBar.style.width = `${status.progress}%`;
      
      if (status.status === 'loading') {
        console.log(`Ładowanie: ${status.progress}%`);
      }
    },
    onSuccess: (loadedData) => {
      console.log('Załadowano kategorie:', Object.keys(loadedData));
      showNotification('Dane załadowane pomyślnie!');
    },
    onError: (error) => {
      console.error('Błąd ładowania:', error.message);
      showErrorDialog(error.message);
    }
  }
});
await bhves.addData({
  entries: [
    {
      name: 'game_logs',
      dataJSONL: `{"start_timestamp":"2025-06-12T14:03:25","type":"error","message":"NullReferenceException w PlayerController"}
{"start_timestamp":"2025-06-12T14:03:30","type":"info","message":"Gracz wszedł do nowego obszaru"}
{"start_timestamp":"2025-06-12T14:03:35","type":"warning","message":"FPS spadł poniżej 30"}`
    },
    {
      name: 'user_interactions', 
      dataJSONL: `{"start_timestamp":"2025-06-12T14:03:22","end_timestamp":"2025-06-12T14:03:24","type":"click","message":"Kliknięcie przycisku Attack"}
{"start_timestamp":"2025-06-12T14:03:28","end_timestamp":"2025-06-12T14:03:32","type":"drag","message":"Przeciągnięcie przedmiotu do inventory"}`
    }
  ],
  options: {
    sortData: true,
    onProgress: (status) => {
      const progressBar = document.getElementById('progress');
      progressBar.style.width = `${status.progress}%`;
      
      if (status.status === 'loading') {
        console.log(`Ładowanie: ${status.progress}%`);
      }
    },
    onSuccess: (loadedData) => {
      console.log('Załadowano kategorie:', Object.keys(loadedData));
      showNotification('Dane załadowane pomyślnie!');
    },
    onError: (error) => {
      console.error('Błąd ładowania:', error.message);
      showErrorDialog(error.message);
    }
  }
});

typescript
import { 
  getMatchingData, 
  getMovingWindowIndexes, 
  getShiftedIndexes,
  getSecondsFromTimestamp 
} from 'betahub-video-events-sync';

function updateEventDisplay(state, data) {
  // 1. Pobierz aktualnie aktywne wydarzenia
  const activeEvents = getMatchingData(state.activeMatchingIndexes, data);
  
  // 2. Utwórz "moving window" - pokazuj kontekst
  const { prepend, append } = getMovingWindowIndexes(
    state.activeMatchingIndexes,
    data,
    {
      prependSize: 3,    // 3 poprzednie
      appendSize: 5,     // 5 następnych  
      minimumSize: 10    // minimum 10 razem
    }
  );
  
  const previousEvents = getMatchingData(prepend, data);
  const upcomingEvents = getMatchingData(append, data);
  
  // 3. Wyświetl w interfejsie
  renderEventsList('active', activeEvents);
  renderEventsList('previous', previousEvents, { opacity: 0.5 });
  renderEventsList('upcoming', upcomingEvents, { opacity: 0.7 });
}

// Nawigacja do konkretnego momentu
function jumpToEvent(eventTimestamp) {
  const videoTime = getSecondsFromTimestamp(
    '2025-06-12T14:03:20', // start nagrania
    eventTimestamp         // moment wydarzenia
  );
  
  document.getElementById('video-player').currentTime = videoTime;
}
import { 
  getMatchingData, 
  getMovingWindowIndexes, 
  getShiftedIndexes,
  getSecondsFromTimestamp 
} from 'betahub-video-events-sync';

function updateEventDisplay(state, data) {
  // 1. Pobierz aktualnie aktywne wydarzenia
  const activeEvents = getMatchingData(state.activeMatchingIndexes, data);
  
  // 2. Utwórz "moving window" - pokazuj kontekst
  const { prepend, append } = getMovingWindowIndexes(
    state.activeMatchingIndexes,
    data,
    {
      prependSize: 3,    // 3 poprzednie
      appendSize: 5,     // 5 następnych  
      minimumSize: 10    // minimum 10 razem
    }
  );
  
  const previousEvents = getMatchingData(prepend, data);
  const upcomingEvents = getMatchingData(append, data);
  
  // 3. Wyświetl w interfejsie
  renderEventsList('active', activeEvents);
  renderEventsList('previous', previousEvents, { opacity: 0.5 });
  renderEventsList('upcoming', upcomingEvents, { opacity: 0.7 });
}

// Nawigacja do konkretnego momentu
function jumpToEvent(eventTimestamp) {
  const videoTime = getSecondsFromTimestamp(
    '2025-06-12T14:03:20', // start nagrania
    eventTimestamp         // moment wydarzenia
  );
  
  document.getElementById('video-player').currentTime = videoTime;
}

typescript
// Obsługa dwóch odtwarzaczy - główny i porównawczy
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', // Inne nagranie
  onStateUpdate: ({ state, data }) => {
    updateComparisonTimeline(state, data);
  }
});

// Ładowanie różnych zestawów danych
await Promise.all([
  mainRecording.addData({
    entries: [{ name: 'session_1_logs', dataJSONL: session1Data }]
  }),
  comparisonRecording.addData({
    entries: [{ name: 'session_2_logs', dataJSONL: session2Data }]
  })
]);
// Obsługa dwóch odtwarzaczy - główny i porównawczy
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', // Inne nagranie
  onStateUpdate: ({ state, data }) => {
    updateComparisonTimeline(state, data);
  }
});

// Ładowanie różnych zestawów danych
await Promise.all([
  mainRecording.addData({
    entries: [{ name: 'session_1_logs', dataJSONL: session1Data }]
  }),
  comparisonRecording.addData({
    entries: [{ name: 'session_2_logs', dataJSONL: session2Data }]
  })
]);

Biblioteka używa formatu JSONL (JSON Lines), gdzie każda linia to osobne wydarzenie w formacie JSON. Ten format został wybrany ze względu na:

  • Efektywność analizy: Każda linia może być przetwarzana niezależnie
  • Kompatybilność: Standardowy format używany w logach i przesyłaniu strumieniowym danych
  • Czytelność: Łatwy do debugowania i ręcznej edycji

typescript
interface Data {
  start_timestamp: string;    // Znacznik czasu początku wydarzenia
  end_timestamp?: string;     // Opcjonalny znacznik czasu końca
  type: string;               // Typ wydarzenia (log, interaction, error, dowolny inny)
  message?: string;           // Treść wiadomości
  details?: object;           // Dodatkowe metadane
}
interface Data {
  start_timestamp: string;    // Znacznik czasu początku wydarzenia
  end_timestamp?: string;     // Opcjonalny znacznik czasu końca
  type: string;               // Typ wydarzenia (log, interaction, error, dowolny inny)
  message?: string;           // Treść wiadomości
  details?: object;           // Dodatkowe metadane
}

json
{"start_timestamp":"2025-06-12T14:03:20","type":"game_start","message":"Rozpoczęcie sesji testowej","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":"Rozpoczęcie sesji testowej","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: Wymagane - znacznik czasu początku wydarzenia
  • end_timestamp: Opcjonalne - dla wydarzeń trwających w czasie
  • type: Wymagane - kategoryzacja wydarzenia
  • message: Opcjonalne - czytelny opis
  • details: Opcjonalne - dodatkowe metadane w formacie obiektu

Problem: Synchronizacja wydarzeń z dokładnością do milisekund była krytyczna dla doświadczenia użytkownika. Różnice nawet 100-200ms byłyby zauważalne podczas analizy nagrań testowych. Często nagrania są analizowane nawet klatka po klatce.

Rozwiązanie:

  • Użycie formatu ISO 8601 dla wszystkich timestampów
  • Parsowanie wszystkich timestampów przy ładowaniu danych

Problem: Sesje testowe mogą generować miliony wydarzeń. Liniowe przeszukiwanie w czasie rzeczywistym miałoby wysoki wpływ na wydajność.

Rozwiązanie:

  • Zaawansowany system indeksowania w DataIndexManager
  • Jednorazowe przetwarzanie wszystkich timestampów przy ładowaniu danych
  • Wyszukiwanie binarne zamiast liniowego dla efektywnego dostępu do danych

Optymalizacje przetwarzania:

  • Przetwarzanie fragmentami (10,000 rekordów na fragment) z setTimeout(0) aby nie blokować UI
  • Przetwarzanie równoległe: możliwość ładowania wielu plików jednocześnie
  • Opcjonalne sortowanie danych przy ładowaniu
  • Podwójne przechowywanie: oryginalne dane dla API, zoptymalizowane dla wyszukiwania

Problem: Biblioteka musi być użyteczna z React, Vue, Angular i vanilla JS, bez narzucania konkretnego stylu implementacji.

Rozwiązanie:

  • Architektura oparta na callbackach, oferująca zarówno maksymalną elastyczność jak i prostotę użycia
  • System stanów zawierający aktualny czas odtwarzacza, aktualny timestamp oraz indeksy wydarzeń
typescript
interface State {
  videoPlayerTimeSeconds: number;
  timestamp: string;
  matchingIndexes: CategoryIndexes;     // Wszystkie aktywne wydarzenia
  activeMatchingIndexes: CategoryIndexes; // Najnowsze z każdej kategorii
}
interface State {
  videoPlayerTimeSeconds: number;
  timestamp: string;
  matchingIndexes: CategoryIndexes;     // Wszystkie aktywne wydarzenia
  activeMatchingIndexes: CategoryIndexes; // Najnowsze z każdej kategorii
}

Kluczowe decyzje projektowe:

  • Separacja matchingIndexes vs activeMatchingIndexes dla różnych przypadków użycia
  • Funkcje pomocnicze zamiast komponentów UI
  • Brak zewnętrznych zależności dla maksymalnej kompatybilności

BetaHub Video Events Sync to specjalistyczna biblioteka, która rozwiązuje realny problem w branży game development - precyzyjną synchronizację wydarzeń czasowych z nagraniami gameplay'u. Jej powstanie było napędzane konkretnymi potrzebami platformy BetaHub w obszarze analizy testów gier.

Ten projekt był fascynującym doświadczeniem w projektowaniu bibliotek.

Utwierdził mnie w przekonaniu, że najlepsze projekty powstają przy rozwiązywaniu realnych, wymagających problemów biznesowych - nie z abstrakcyjnych założeń technicznych.

©2025 BatStack