465 lines
14 KiB
Markdown
465 lines
14 KiB
Markdown
# 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:
|
|
```go
|
|
// 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:
|
|
```go
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```go
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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. |