nextav/docs/04-progress-bar-accuracy.md

14 KiB

Live Transcoding Progress Bar Accuracy - Anti-Jitter Mechanisms

Core Problem Analysis

Live transcoding creates unique challenges for accurate progress reporting:

  1. Duration Drift: Transcoded segments may have slightly different durations than expected
  2. Buffer Timing: Real-time transcoding can't provide accurate total duration until completion
  3. Seek Inconsistency: Seeking to unbuffered positions causes progress jumps
  4. Network Variability: Connection issues cause buffering delays
  5. Segment Validation: Incomplete or corrupted segments affect progress accuracy

Stash's Anti-Jitter Strategy

1. Precise Duration Extraction

From ffprobe.go - Accurate duration calculation:

// pkg/ffmpeg/ffprobe.go - parse function
func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
    result := &VideoFile{}
    
    // Primary duration from video stream
    duration, _ := strconv.ParseFloat(videoStream.Duration, 64)
    result.VideoStreamDuration = math.Round(duration*100) / 100
    
    // Fallback to container duration with rounding
    if result.VideoStreamDuration == 0 {
        duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
        result.VideoStreamDuration = math.Round(duration*100) / 100
    }
    
    // Store both for validation
    result.FileDuration = result.VideoStreamDuration
    return result
}

2. Segment-Based Progress Tracking

From stream_segmented.go - Atomic segment generation:

// internal/manager/stream_segmented.go
func (tp *transcodeProcess) serveHLSManifest() []byte {
    var buf bytes.Buffer
    buf.WriteString("#EXTM3U\n")
    buf.WriteString("#EXT-X-VERSION:3\n")
    buf.WriteString("#EXT-X-TARGETDURATION:2\n")
    
    // Pre-calculated exact durations
    leftover := tp.videoFile.VideoStreamDuration
    segment := 0
    for leftover > 0 {
        thisLength := math.Min(float64(segmentLength), leftover)
        fmt.Fprintf(&buf, "#EXTINF:%f,\n", thisLength)
        fmt.Fprintf(&buf, "segment_%06d.ts\n", segment)
        leftover -= thisLength
        segment++
    }
    
    return buf.Bytes()
}

3. Anti-Jitter Mechanisms

A. Segment Atomicity

// Frontend segment validation
interface SegmentValidation {
  segmentNumber: number;
  expectedDuration: number;
  actualDuration?: number;
  isComplete: boolean;
  isValid: boolean;
}

const validateSegment = async (
  segmentNumber: number,
  expectedDuration: number
): Promise<SegmentValidation> => {
  try {
    const response = await fetch(`/api/segments/${segmentNumber}`);
    const contentLength = response.headers.get('content-length');
    const actualDuration = await calculateDurationFromBytes(
      parseInt(contentLength || '0')
    );
    
    return {
      segmentNumber,
      expectedDuration,
      actualDuration,
      isComplete: actualDuration >= expectedDuration * 0.95,
      isValid: actualDuration >= expectedDuration * 0.8
    };
  } catch (error) {
    return {
      segmentNumber,
      expectedDuration,
      isComplete: false,
      isValid: false
    };
  }
};

B. Buffer State Management

// From ScenePlayerScrubber.tsx - Smooth progress updates
const useSmoothProgress = (currentTime: number, duration: number) => {
  const [displayTime, setDisplayTime] = useState(currentTime);
  
  useEffect(() => {
    const targetTime = Math.min(currentTime, duration);
    const diff = targetTime - displayTime;
    
    // Only update if significant change or approaching end
    if (Math.abs(diff) > 0.1 || targetTime >= duration - 1) {
      setDisplayTime(targetTime);
    }
  }, [currentTime, duration]);
  
  return displayTime;
};

C. Seek Position Validation

// Validate seek positions against actual buffer state
const validateSeekPosition = (
  requestedTime: number,
  bufferedRanges: TimeRanges,
  duration: number
): number => {
  let validTime = requestedTime;
  
  // Find nearest buffered segment
  for (let i = 0; i < bufferedRanges.length; i++) {
    if (
      requestedTime >= bufferedRanges.start(i) && 
      requestedTime <= bufferedRanges.end(i)
    ) {
      return requestedTime; // Exact match
    }
    
    if (requestedTime < bufferedRanges.start(i)) {
      validTime = bufferedRanges.start(i); // Snap to nearest available
      break;
    }
  }
  
  return Math.max(0, Math.min(validTime, duration));
};

Real-time Transcoding Coordination

Backend Process Monitoring

// From internal/manager/stream_segmented.go
type transcodeProcess struct {
    videoFile    *models.VideoFile
    outputDir    string
    segmentCount int
    
    // Real-time state tracking
    lastSegment  int
    waitingSegment chan int
    
    // Progress synchronization
    mu           sync.RWMutex
    currentTime  float64
    totalTime    float64
}

func (tp *transcodeProcess) updateProgress(segment int, duration float64) {
    tp.mu.Lock()
    tp.currentTime = float64(segment) * 2.0 // 2-second segments
    tp.mu.Unlock()
    
    // Notify waiting clients
    select {
    case tp.waitingSegment <- segment:
    default:
    }
}

func (tp *transcodeProcess) getProgress() (float64, float64) {
    tp.mu.RLock()
    defer tp.mu.RUnlock()
    return tp.currentTime, tp.totalTime
}

Frontend Smoothing Layers

Multi-layer Progress Smoothing

// Progressive enhancement for progress accuracy
const useAccurateProgress = (
  playerRef: RefObject<HTMLVideoElement>,
  totalDuration: number
) => {
  const [progress, setProgress] = useState(0);
  const [buffered, setBuffered] = useState(0);
  const [isTranscoding, setIsTranscoding] = useState(true);
  
  useEffect(() => {
    const video = playerRef.current;
    if (!video) return;
    
    // Primary source: video.currentTime
    const handleTimeUpdate = debounce(() => {
      const newProgress = video.currentTime / totalDuration;
      
      // Prevent backward jumps
      if (newProgress >= progress || newProgress >= 0.99) {
        setProgress(Math.min(newProgress, 1));
      }
    }, 100);
    
    // Secondary source: buffered ranges
    const handleProgress = debounce(() => {
      const bufferedEnd = video.buffered.length > 0 
        ? video.buffered.end(video.buffered.length - 1) 
        : 0;
      setBuffered(bufferedEnd / totalDuration);
    }, 250);
    
    // Debounced updates for smooth UI
    const debouncedTimeUpdate = debounce(handleTimeUpdate, 100);
    const debouncedProgress = debounce(handleProgress, 250);
    
    video.addEventListener('timeupdate', debouncedTimeUpdate);
    video.addEventListener('progress', debouncedProgress);
    
    return () => {
      video.removeEventListener('timeupdate', debouncedTimeUpdate);
      video.removeEventListener('progress', debouncedProgress);
    };
  }, [playerRef, totalDuration]);
  
  return { progress, buffered, isTranscoding };
};

Next.js Implementation Strategy

API Route with Progress Tracking

// pages/api/transcode/[...slug].ts
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { file, quality, start } = req.query;
  
  // 1. Extract precise duration first
  const metadata = await extractVideoMetadata(file as string);
  res.setHeader('X-Total-Duration', metadata.duration);
  
  // 2. Segment-based streaming
  const segmentDuration = 2; // 2-second segments
  const totalSegments = Math.ceil(metadata.duration / segmentDuration);
  
  // 3. Generate accurate manifest
  const manifest = generateHLSManifest(totalSegments, segmentDuration, metadata.duration);
  
  res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
  res.send(manifest);
}

// Segment serving with validation
export const serveSegment = async (req: NextApiRequest, res: NextApiResponse) => {
  const { segment } = req.query;
  const segmentPath = `segments/segment_${segment.toString().padStart(6, '0')}.ts`;
  
  // Validate segment exists and is complete
  try {
    const stats = await fs.stat(segmentPath);
    if (stats.size === 0) {
      return res.status(404).end();
    }
    
    // Check segment integrity
    const isValid = await validateSegmentIntegrity(segmentPath);
    if (!isValid) {
      return res.status(410).end(); // Gone - segment invalid
    }
    
    res.setHeader('Content-Type', 'video/mp2t');
    res.setHeader('Content-Length', stats.size);
    
    const stream = createReadStream(segmentPath);
    stream.pipe(res);
    
    // Cleanup on client disconnect
    req.on('close', () => {
      stream.destroy();
    });
  } catch (error) {
    res.status(404).end();
  }
};

Client-Side Integration

// hooks/useTranscodeProgress.ts
import { useState, useEffect, useRef } from 'react';

export const useTranscodeProgress = (
  videoUrl: string,
  totalDuration: number
) => {
  const [progress, setProgress] = useState(0);
  const [buffered, setBuffered] = useState(0);
  const [isTranscoding, setIsTranscoding] = useState(true);
  const [quality, setQuality] = useState('720p');
  
  const videoRef = useRef<HTMLVideoElement>(null);
  const progressRef = useRef(0);
  const lastUpdateRef = useRef(Date.now());
  
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    
    // Prevent backward jumps and smooth updates
    const handleTimeUpdate = () => {
      const now = Date.now();
      if (now - lastUpdateRef.current < 100) return; // Throttle updates
      
      const newProgress = video.currentTime / totalDuration;
      
      // Only allow forward progress or small backward adjustments
      if (newProgress >= progressRef.current - 0.005) {
        progressRef.current = newProgress;
        setProgress(newProgress);
        lastUpdateRef.current = now;
      }
    };
    
    // Monitor transcoding completion
    const checkTranscodingStatus = async () => {
      try {
        const response = await fetch(`/api/transcode/status?url=${encodeURIComponent(videoUrl)}`);
        const data = await response.json();
        setIsTranscoding(data.transcoding);
        
        // Update total duration if changed
        if (data.duration && Math.abs(data.duration - totalDuration) > 1) {
          // Handle duration correction
        }
      } catch (error) {
        console.error('Failed to check transcoding status:', error);
      }
    };
    
    // Monitor buffer state
    const handleBufferUpdate = () => {
      if (video.buffered.length > 0) {
        const bufferedEnd = video.buffered.end(video.buffered.length - 1);
        setBuffered(bufferedEnd / totalDuration);
      }
    };
    
    video.addEventListener('timeupdate', handleTimeUpdate);
    video.addEventListener('progress', handleBufferUpdate);
    const interval = setInterval(checkTranscodingStatus, 2000);
    
    return () => {
      video.removeEventListener('timeupdate', handleTimeUpdate);
      video.removeEventListener('progress', handleBufferUpdate);
      clearInterval(interval);
    };
  }, [videoUrl, totalDuration]);
  
  return {
    progress,
    buffered,
    isTranscoding,
    videoRef,
    quality,
    setQuality
  };
};

Advanced Anti-Jitter Features

1. Predictive Buffering

// Predict next segment and pre-buffer
const usePredictiveBuffering = (
  currentTime: number,
  totalDuration: number,
  playbackRate: number
) => {
  const [nextSegment, setNextSegment] = useState<number | null>(null);
  
  useEffect(() => {
    const currentSegment = Math.floor(currentTime / 2); // 2-second segments
    const bufferAhead = Math.ceil(playbackRate * 10); // 10 seconds ahead
    const targetSegment = currentSegment + bufferAhead;
    
    if (targetSegment * 2 < totalDuration) {
      setNextSegment(targetSegment);
    }
  }, [currentTime, totalDuration, playbackRate]);
  
  return nextSegment;
};

2. Quality Adaptation

// Adapt quality based on buffer state
const useAdaptiveQuality = (
  buffered: number,
  progress: number,
  bandwidth: number
) => {
  const [quality, setQuality] = useState('720p');
  
  useEffect(() => {
    const bufferRatio = buffered - progress;
    
    if (bufferRatio < 0.1) {
      setQuality('480p'); // Lower quality for poor buffering
    } else if (bandwidth > 5000000) {
      setQuality('1080p'); // Higher quality for good bandwidth
    } else {
      setQuality('720p'); // Default quality
    }
  }, [buffered, progress, bandwidth]);
  
  return quality;
};

Key Implementation Guidelines

1. Duration Accuracy

  • Always extract duration from video stream metadata, not container
  • Use ffprobe/ffmpeg for precise duration extraction
  • Validate duration against actual transcoded segments
  • Handle edge cases (variable frame rate, corrupted metadata)

2. Progress Synchronization

  • Use segment-based streaming (HLS/DASH) for granular control
  • Implement atomic segment generation (never overwrite existing)
  • Provide real-time transcoding status to frontend
  • Handle dynamic duration changes gracefully

3. Anti-Jitter Strategies

  • Segment Validation: Only serve complete, validated segments
  • State Consistency: Maintain consistent state between backend and frontend
  • Smooth Updates: Debounce progress updates and prevent backward jumps
  • Buffer Awareness: Track actual buffer state vs transcoding progress

4. Error Handling

  • Graceful Degradation: Remove incomplete segments rather than serving corrupted data
  • Timeout Management: 15-second timeout for segment generation
  • Fallback Strategies: Multiple quality levels for connection issues
  • Recovery Mechanisms: Restart failed segments without user intervention

This comprehensive approach ensures smooth, accurate progress bars even during live transcoding, eliminating the jittery behavior common in streaming applications.