Skip to main content

Video Synchronization Architecture

This document explains the video synchronization system in HyperStudy, including the recent improvements that optimize socket connections and media preloading.

Overview

HyperStudy uses a sophisticated video synchronization system to ensure that multiple participants view video content at precisely the same time. The system employs:

  • Conditional sync socket creation - Only creates sync connections when actually needed
  • Kalman filter-based prediction - Predicts optimal playback position
  • PID controller - Adjusts playback rate for smooth synchronization
  • Comprehensive media preloading - Ensures videos are ready before playback begins
  • Role-based architecture - Host controls playback, viewers follow

When Video Synchronization is Required

Video synchronization is conditionally enabled based on two criteria:

  1. Multi-participant experiments (participantCount > 1)
  2. Presence of ShowVideo components in experiment states

Backend Detection

The backend determines if sync is required when all participants have joined:

// From backend/src/experiment/experimentStateServer.js
const requiresSync = room.experimentData?.states?.some(s =>
s.focusComponent?.type?.toLowerCase() === 'showvideo'
) && room.expectedCount > 1;

This information is sent to the frontend via the backend-experiment-setup-complete event:

this.io.of('/experiment').to(`room:${roomId}`).emit('backend-experiment-setup-complete', {
experimentData: room.experimentData,
requiresSync // Boolean flag indicating if sync socket is needed
});

Frontend Detection

The frontend also has a helper function for UI components to check if sync is required:

// From frontend/src/lib/media/deviceManager.svelte.js
export function experimentRequiresSync(experiment, participantCount) {
// Single participant never needs sync
if (!experiment || participantCount <= 1) {
return false;
}

// Check if any state has a showVideo component
return experiment.states?.some(state => {
const type = state.focusComponent?.type?.toLowerCase();
return type === 'showvideo';
});
}

Architecture Components

1. Sync Socket Connection

The sync socket is a separate Socket.IO namespace (/sync) dedicated to high-frequency synchronization updates.

Conditional Connection

The sync socket is only created when needed to optimize resource usage:

// From frontend/src/components/experiment/ExperimentRunner.svelte
if (requiresSync) {
logger.info("Adding sync socket setup to parallel tasks");
setupTasks.push(
connectSyncSocketWithRoom(roomId).then(socket => {
syncSocket = socket;
logger.info("✅ Sync socket connected and ready");
})
);
} else {
logger.info("ℹ️ Sync socket not required for this experiment");
syncSocket = null;
}

Room-Based Routing

The sync socket includes the roomId in its connection query to ensure proper pod routing in horizontally scaled deployments:

// From frontend/src/lib/network/socketClient.js
export async function connectSyncSocketWithRoom(roomId) {
// Create new sync socket with roomId in query for pod routing
syncSocket = io(`${backendUrl}/sync`, {
reconnection: true,
reconnectionAttempts: 15,
// ... other options
forceNew: true, // Force new connection for pod routing
multiplex: false, // Don't multiplex - need separate connection
query: {
roomId: roomId // CRITICAL: Include roomId for pod routing
}
});

syncSocket.connect();
return syncSocket;
}

2. ShowVideoComponent

The ShowVideoComponent is the main component responsible for synchronized video playback.

Sync Socket Prop

The component receives the sync socket as a prop from ExperimentRunner:

<!-- From frontend/src/components/experiment/ShowVideoComponent.svelte -->
<script>
const {
config = {},
oncomplete = () => {},
onresponse = () => {},
onready = () => {},
syncSocket = null // Sync socket passed from ExperimentRunner (null for single-participant)
} = $props();
</script>

Single vs Multi-Participant Behavior

Single Participant (syncSocket === null):

  • No synchronization needed
  • Video plays normally with browser controls
  • No network overhead from sync updates

Multi-Participant (syncSocket !== null):

  • Host controls playback
  • Viewers synchronize to host's position
  • High-frequency time updates via sync socket

3. Role-Based Synchronization

Host Role

The host participant (determined by config.hostRole) controls video playback:

function isParticipantHost() {
const role = ($participantRole || "").toLowerCase();
const hostRole = (config.hostRole || "host").toLowerCase();
return role === hostRole;
}

Host Responsibilities:

  • Controls play/pause/seek operations
  • Broadcasts time updates every 100ms
  • Sends initial state to newly joined viewers
  • Has browser controls enabled

Host Events Emitted:

  • exp:time-update - Current position and state (100ms interval)
  • exp:playback-command - Play/pause commands
  • exp:seek-command - Seek operations
  • exp:initial-state - Full state snapshot for new viewers

Viewer Role

Viewers follow the host's playback state:

Viewer Responsibilities:

  • Listen for host commands
  • Calculate synchronization drift
  • Adjust playback rate to maintain sync
  • Report sync metrics to server

Viewer Events Listened To:

  • exp:initial-state - Receive initial video state
  • exp:time-update - Track host's current position
  • exp:playback-command - Execute play/pause
  • exp:seek-command - Jump to new position

Viewer Events Emitted:

  • exp:request-initial-state - Request state on join
  • exp:ping-request - Measure network latency
  • exp:sync-metrics - Report drift and performance

4. Synchronization Engine

The sync engine uses a Kalman filter and PID controller for smooth, accurate synchronization.

Initialization

Viewers initialize the sync engine when the component mounts:

// Initialize sync engine for viewers only
syncEngine = initExperimentSyncEngine({
isHost: false,
videoElement,
roomId: $roomStore.id,
onSync: (syncData) => {
Object.assign(syncState, syncData);
},
onError: (error) => {
logger.error("Sync engine error:", error);
}
});

// CRITICAL: Start sync checking interval for viewers
syncInterval = setInterval(checkSync, 100);

Important: The sync check interval must be started for viewers to calculate drift and adjust playback. Without this interval, viewers will not synchronize properly.

Configuration Parameters

// From frontend/src/lib/media/experimentSyncEngine.js
const config = {
// Synchronization thresholds
seekThreshold: 0.6, // When to seek vs. adjust rate (seconds)
seekCooldown: 2500, // Minimum time between seeks (ms)
minPlaybackRate: 0.95, // Minimum allowed playback rate
maxPlaybackRate: 1.05, // Maximum allowed playback rate
maxRateChange: 0.01, // Maximum rate change per calculation

// Kalman filter parameters
processNoise: {
position: 0.008, // Process noise for position
velocity: 0.008, // Process noise for velocity
acceleration: 0.01 // Process noise for acceleration
},
measurementNoise: 0.15, // Measurement noise

// PID controller gains
pid: {
proportional: 0.45, // Proportional gain
integral: 0.1, // Integral gain
derivative: 0.02 // Derivative gain
}
};

Sync Algorithm

The viewer continuously checks sync and adjusts playback:

function checkSync() {
const currentTime = videoElement.currentTime;
const { hostTime, drift, playbackRate } = syncEngine.calculateSync();

// Large drift requires seeking
if (Math.abs(drift) > syncEngine.seekThreshold &&
Date.now() - lastSeekTime > syncEngine.seekCooldown) {
lastSeekTime = Date.now();
videoElement.currentTime = hostTime;
reportSyncMetrics();
}
// Small drift adjusts playback rate
else if (!syncState.hostPaused) {
videoElement.playbackRate = playbackRate;
}
}

Media Preloading

A critical improvement is comprehensive media preloading that happens for all participants before the experiment starts.

Preloading Architecture

When Preloading Occurs

Preloading happens in the handleBackendSetupComplete phase, before any other frontend setup:

// From frontend/src/components/experiment/ExperimentRunner.svelte
async function handleBackendSetupComplete(event) {
const { experimentData, requiresSync } = event.detail;

// CRITICAL: Preload all media for ALL participants before anything else!
logger.info("📥 Starting media preload for all participants");
await preloadExperimentMedia(experimentData);
logger.info("✅ Media preload complete");

// Then setup LiveKit, sync socket, etc.
// ...
}

What Gets Preloaded

The system preloads:

  • Images from ShowImage components
  • Videos from ShowVideo components
  • Role-specific media from component roleConfigs
async function preloadExperimentMedia(experimentData) {
const imageUrls = [];
const videoUrls = [];

// Process each state in the experiment
experimentData.states.forEach((state) => {
const componentType = state.focusComponent?.type?.toLowerCase();
const config = state.focusComponent?.config || {};

// Extract image URLs
if (componentType === "showimage" && config.imageUrl) {
const processedUrl = processImageUrl(config.imageUrl);
if (processedUrl) imageUrls.push(processedUrl);
}

// Extract video URLs
if (componentType === "showvideo" && config.videoUrl) {
videoUrls.push(config.videoUrl);
}

// Also check role-specific configurations
if (state.focusComponent.roleConfigs) {
Object.values(state.focusComponent.roleConfigs).forEach((roleConfig) => {
if (roleConfig?.imageUrl) {
const processedUrl = processImageUrl(roleConfig.imageUrl);
if (processedUrl) imageUrls.push(processedUrl);
}
if (roleConfig?.videoUrl) {
videoUrls.push(roleConfig.videoUrl);
}
});
}
});

// Preload all unique URLs
const uniqueImageUrls = [...new Set(imageUrls)];
const uniqueVideoUrls = [...new Set(videoUrls)];

if (uniqueImageUrls.length > 0) {
await preloadImages(uniqueImageUrls);
}

if (uniqueVideoUrls.length > 0) {
for (const url of uniqueVideoUrls) {
await mediaPreloader.preloadVideo(url, { quickLoad: true });
}
}
}

Why Preload All Participants?

Even though viewers don't have browser controls, all participants benefit from preloading:

  1. Eliminates race conditions - Video is ready before sync socket sends initial state
  2. Reduces loading delays - No waiting for video metadata when state changes
  3. Improves initial sync - Viewers can seek immediately when receiving host position
  4. Prevents grey video boxes - Video element renders properly from the start

Event Flow and Timing

Initialization Sequence

1. Backend Setup (All Participants Join)

2. Backend sends: backend-experiment-setup-complete
- Includes: experimentData, requiresSync flag

3. Frontend Setup (Parallel)
- Preload ALL media (images + videos) for ALL participants
- Initialize LiveKit (if needed)
- Connect sync socket (if requiresSync === true)

4. ShowVideoComponent Mounts
- Receives syncSocket prop (null or socket instance)
- Sets up role-based event listeners
- If multi-participant: joins sync room

5. Video Playback Begins
- Host: controls enabled, broadcasts state
- Viewers: controls disabled, follow host

Sync Event Loop (Multi-Participant)

Host:
100ms interval → exp:time-update → Server → Viewers

Viewer:
Receives exp:time-update

syncEngine.updateHostPosition()

checkSync() calculates drift

Adjust playback rate OR seek

Report metrics (1s interval)

Loading State Management

The video component carefully manages loading states to prevent visual glitches:

<script>
let videoLoading = $state(true);
let videoReady = $state(false);

// Event handlers
videoElement.addEventListener('loadedmetadata', () => {
videoLoading = false;
handleVideoLoaded(); // Apply start time, etc.

// Host sends initial state when metadata is ready
if (syncSocket && isParticipantHost()) {
sendInitialState();
}
});

videoElement.addEventListener('canplaythrough', () => {
videoReady = true;
});
</script>

<!-- Only show loading if video is truly loading -->
{#if videoLoading && !videoReady && (!videoElement || videoElement.readyState < 2)}
<div class="video-loading-state">
<div class="loading-spinner"></div>
<p>Loading video...</p>
</div>
{/if}

<!-- Hide video while loading to prevent grey box -->
<video
bind:this={videoElement}
class:video-hidden={videoLoading && !videoReady}
>

Video Autoplay Behavior

Autoplay Implementation

The system uses a single, streamlined autoplay function that works for both preloaded and non-preloaded videos:

function attemptAutoplay() {
// Only hosts need autoplay (viewers follow host commands)
if (!isParticipantHost() || !videoElement || config.autoPlay === false) {
return;
}

// Only try if video is paused and has enough data
if (!videoElement.paused || videoElement.readyState < 3) {
return;
}

videoElement.play().catch(e => {
logger.warn("Autoplay was blocked by browser:", e);
// This is expected behavior in some browsers - user must click play
});
}

Key Principles:

  • No browser-specific hacks - Maintains experiment timing integrity
  • Single autoplay path - Eliminates race conditions
  • Respects browser policies - Some browsers may block autoplay
  • Host-only - Viewers don't need autoplay as they follow host commands

Configuration

Component Configuration

Configure the ShowVideo component in the experiment designer:

{
type: "ShowVideo",
config: {
videoUrl: "https://example.com/video.mp4",
hostRole: "host", // Which role controls playback
controls: true, // Show controls (host only)
autoPlay: true, // Auto-play when loaded (host only)
startTime: 0, // Start position (seconds)
endTime: 0, // End position (0 = play to end)
videoSize: 50, // Size (% of container)
horizontalPosition: "center", // left, center, right
verticalPosition: "center" // upper, center, lower
}
}

Role-Specific Configurations

Different roles can see different videos:

{
type: "ShowVideo",
config: {
hostRole: "host"
},
roleConfigs: {
host: {
videoUrl: "https://example.com/host-view.mp4",
controls: true
},
viewer: {
videoUrl: "https://example.com/viewer-perspective.mp4",
controls: false
}
}
}

Performance Considerations

Network Efficiency

  1. Conditional socket creation - Only creates sync socket when multi-participant + video
  2. High-frequency updates - 100ms broadcast interval balances precision and bandwidth
  3. Adaptive metrics - Viewers report metrics every 1s, not every sync check

Synchronization Precision

  1. Kalman filtering - Predicts optimal position based on network conditions
  2. Network latency compensation - Measures and adjusts for round-trip time
  3. Smooth rate adjustments - PID controller prevents jarring speed changes
  4. Seek thresholds - Only seeks when drift exceeds 600ms

Resource Management

  1. Socket cleanup - Properly disconnects sync socket on component destroy
  2. Event listener management - Selective listener registration based on role
  3. Memory efficiency - No sync engine for host, only for viewers

Troubleshooting

Common Issues

Viewers Not Syncing

Symptoms: Viewer video doesn't follow host position, drift shows 0.000s

Possible Causes:

  1. Sync socket not connected
  2. Event listeners not registered
  3. Video not preloaded
  4. checkSync interval not started (critical bug fixed in Dec 2024)

Debug Steps:

// Check sync socket connection
console.log('Sync socket:', syncSocket?.connected);

// Check sync engine
console.log('Sync engine:', syncEngine);

// Check video ready state
console.log('Video readyState:', videoElement?.readyState);

// Verify checkSync is running (should see periodic logs)
// Look for "Sync check:" logs in console

Solution: Ensure syncInterval = setInterval(checkSync, 100) is called for viewers after sync engine initialization.

Video Not Loading

Symptoms: Grey box or loading spinner persists

Possible Causes:

  1. Video URL inaccessible
  2. CORS issues
  3. Preloading failed

Debug Steps:

// Check preload stats
const stats = mediaPreloader.getStats();
console.log('Preload stats:', stats);

// Check video element
console.log('Video src:', videoElement?.src);
console.log('Video error:', videoElement?.error);

Sync Drift Too Large

Symptoms: Viewer video constantly seeking or out of sync

Possible Causes:

  1. High network latency
  2. Video buffering issues
  3. Sync parameters need tuning

Debug Steps:

// Check sync metrics
console.log('Drift:', syncState.drift);
console.log('Network latency:', syncState.networkLatency);
console.log('Playback rate:', syncState.playbackRate);

Debug Mode

Enable debug overlay in ShowVideo component configuration:

{
config: {
showDebug: true
}
}

This displays:

  • Current role (Host/Viewer)
  • Sync status
  • Drift measurement
  • Host vs viewer time

Code Examples

Using ShowVideoComponent

<!-- In ExperimentRunner.svelte -->
<script>
let syncSocket = $state(null);

// After backend setup complete
if (requiresSync) {
syncSocket = await connectSyncSocketWithRoom(roomId);
}
</script>

<!-- Render video component with sync socket prop -->
<ShowVideoComponent
id={focusComponent.id}
config={focusComponent.config}
syncSocket={syncSocket}
oncomplete={handleComplete}
/>

Manual Sync Socket Connection

import { connectSyncSocketWithRoom } from '$lib/network/socketClient.js';

// Connect sync socket for a specific room
try {
const socket = await connectSyncSocketWithRoom(roomId);
console.log('Sync socket connected:', socket.id);
} catch (error) {
console.error('Failed to connect sync socket:', error);
}

Checking Sync Requirements

import { experimentRequiresSync } from '$lib/media/deviceManager.svelte.js';

// Determine if sync is needed
const needsSync = experimentRequiresSync(experimentData, participantCount);

if (needsSync) {
console.log('This experiment requires video synchronization');
} else {
console.log('No video sync needed');
}

Recent Updates

December 2024 Fixes

  • Fixed critical viewer sync bug - Added missing setInterval(checkSync, 100) for viewers
  • Streamlined autoplay logic - Single function for both preloaded and non-preloaded videos
  • Removed browser-specific hacks - No more Firefox workarounds or delays
  • Fixed video end transitions - Properly triggers state transitions on video completion
  • Improved sync indicator - No longer auto-hides, shows throughout experiment

Future Improvements

Potential enhancements to the synchronization system:

  1. Adaptive sync parameters - Automatically adjust based on network conditions
  2. Sub-second precision - Reduce drift threshold for even tighter sync
  3. Bandwidth optimization - Compress time update payloads
  4. Predictive buffering - Pre-buffer video segments for viewers
  5. Quality of Service monitoring - Track and report sync health metrics