BetaHub Video Events Sync
3 lipca 2025Zaawansowana 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:
typescriptconst 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 zindeksowaneonError
: 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 wideoonStateUpdate
: 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.
typescriptprivate data: { [key: string]: Data[] } = {}; // Format oryginalny private dataInternal: { [key: string]: DataInternal[] } = {}; // Format zoptymalizowany pod wyszukiwanieprivate 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
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
Diagram pokazuje przepływ danych podczas typowego cyklu życia:
- Inicjalizacja: Utworzenie instancji i podłączenie do video
- Ładowanie danych: Przetwarzanie JSONL i budowanie indeksów
- Runtime: Ciągła synchronizacja podczas odtwarzania
- Callbacks: Powiadamianie o zmianach stanu
typescriptconst 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.
typescriptimport { 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); } });
typescriptawait 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); } } });
typescriptimport { 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
typescriptinterface 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 wydarzeniaend_timestamp
: Opcjonalne - dla wydarzeń trwających w czasietype
: Wymagane - kategoryzacja wydarzeniamessage
: Opcjonalne - czytelny opisdetails
: 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ń
typescriptinterface 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
vsactiveMatchingIndexes
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.