14 KiB
14 KiB
Live Transcoding Progress Bar Accuracy - Anti-Jitter Mechanisms
Core Problem Analysis
Live transcoding creates unique challenges for accurate progress reporting:
- Duration Drift: Transcoded segments may have slightly different durations than expected
- Buffer Timing: Real-time transcoding can't provide accurate total duration until completion
- Seek Inconsistency: Seeking to unbuffered positions causes progress jumps
- Network Variability: Connection issues cause buffering delays
- 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.