15 KiB
15 KiB
Stash Analysis and Solution Plan
🔍 Real Problem Analysis
The Core Issue: FFmpeg Process Management, Not Duration Protection
After analyzing the docs and your Stash discovery, the real problem is NOT duration corruption or progress bar jitter. The real problem is:
- ❌ Poor FFmpeg Process Management: Our current system doesn't handle FFmpeg processes robustly
- ❌ No Seek-Optimized Transcoding: We don't restart FFmpeg with
--ssfor seeking like Stash does - ❌ Resource Leaks: FFmpeg processes may not terminate properly, causing system issues
- ❌ Inefficient Seeking: Seeking requires transcoding from the beginning instead of from the seek point
What We Were Fixing vs. What We Should Fix
| What We Were Fixing | What We Should Fix |
|---|---|
| ❌ Duration corruption during transcoding | ✅ FFmpeg process lifecycle management |
| ❌ Progress bar jitter | ✅ Seek-optimized transcoding with --ss |
| ❌ Anti-jitter mechanisms | ✅ Process cleanup and resource management |
| ❌ Complex duration validation | ✅ Simple, reliable FFmpeg process handling |
🎯 Stash's Brilliant Approach
Key Discovery: Process Restart on Seek
From your stash.md analysis, Stash does something incredibly smart:
PID 559: ffmpeg -ss 29.848541666666662 -i video.avi ... (seek to 29.8s)
PID 711: ffmpeg -ss 77.374375 -i video.avi ... (seek to 77.3s)
PID 741: ffmpeg -ss 103.31072916666666 -i video.avi ... (seek to 103.3s)
Every seek starts a NEW FFmpeg process with --ss (start time) parameter!
Why This is Brilliant
- 🎯 Instant Seeking: No need to wait for transcoding from beginning
- 🔄 Clean Process Management: Each seek = new process, old process can be killed
- 💾 Memory Efficiency: No need to buffer from start to seek point
- ⚡ Performance: Direct start at desired position
- 🧹 Resource Cleanup: Easy to kill old processes
Stash's Process Flow
User seeks to 30s → Kill current FFmpeg → Start new FFmpeg with -ss 30
User seeks to 60s → Kill current FFmpeg → Start new FFmpeg with -ss 60
User seeks to 90s → Kill current FFmpeg → Start new FFmpeg with -ss 90
🏗️ Proposed Solution Architecture
Phase 1: Robust FFmpeg Process Management
1.1 Process Registry System
// lib/ffmpeg/process-registry.ts
class FFmpegProcessRegistry {
private processes = new Map<string, {
process: ChildProcess;
startTime: Date;
seekTime: number;
videoId: string;
command: string[];
}>();
// Register new process
register(videoId: string, seekTime: number, process: ChildProcess, command: string[]) {
const key = `${videoId}_${seekTime}`;
// Kill existing process for this video if different seek time
this.killExisting(videoId, seekTime);
this.processes.set(key, {
process,
startTime: new Date(),
seekTime,
videoId,
command
});
}
// Kill existing process for video (different seek time)
private killExisting(videoId: string, newSeekTime: number) {
for (const [key, entry] of this.processes.entries()) {
if (entry.videoId === videoId && entry.seekTime !== newSeekTime) {
console.log(`[FFMPEG] Killing existing process for ${videoId} (seek: ${entry.seekTime}s)`);
this.killProcess(key);
}
}
}
// Kill specific process
killProcess(key: string) {
const entry = this.processes.get(key);
if (entry && !entry.process.killed) {
entry.process.kill('SIGKILL');
this.processes.delete(key);
}
}
// Kill all processes for a video
killAllForVideo(videoId: string) {
for (const [key, entry] of this.processes.entries()) {
if (entry.videoId === videoId) {
this.killProcess(key);
}
}
}
// Get process info
getProcessInfo(videoId: string) {
return Array.from(this.processes.entries())
.filter(([_, entry]) => entry.videoId === videoId)
.map(([key, entry]) => ({
key,
seekTime: entry.seekTime,
uptime: Date.now() - entry.startTime.getTime(),
command: entry.command
}));
}
}
1.2 Enhanced Transcoding API
// pages/api/stream/[id]/transcode.ts
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
const { seek } = req.query; // New: seek time parameter
const seekTime = seek ? parseFloat(seek as string) : 0;
// Kill existing process for this video
ffmpegRegistry.killAllForVideo(id as string);
// Build FFmpeg command with seek
const ffmpegArgs = [
'-hide_banner',
'-v', 'error',
...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Add seek if specified
'-i', videoPath,
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
'-preset', 'veryfast',
'-crf', '25',
'-sc_threshold', '0',
'-movflags', 'frag_keyframe+empty_moov',
'-ac', '2',
'-f', 'mp4',
'pipe:1'
];
console.log(`[FFMPEG] Starting transcoding for video ${id} with seek: ${seekTime}s`);
console.log(`[FFMPEG] Command: ffmpeg ${ffmpegArgs.join(' ')}`);
const ffmpeg = spawn('ffmpeg', ffmpegArgs);
// Register process
ffmpegRegistry.register(id as string, seekTime, ffmpeg, ffmpegArgs);
// Handle cleanup
req.on('close', () => {
console.log(`[FFMPEG] Client disconnected, killing process for video ${id}`);
ffmpegRegistry.killAllForVideo(id as string);
});
// Stream response
res.setHeader('Content-Type', 'video/mp4');
res.setHeader('X-Content-Duration', videoDuration.toString());
res.setHeader('X-Seek-Time', seekTime.toString());
ffmpeg.stdout.pipe(res);
}
Phase 2: Frontend Seek Integration
2.1 Enhanced Video Player
// components/video-viewer.tsx
const handleSeek = (newTime: number) => {
if (videoRef.current) {
console.log(`[PLAYER] Seeking to ${newTime}s`);
// Kill current transcoding process
const videoId = getVideoId();
if (videoId && isTranscoding) {
console.log(`[PLAYER] Killing current transcoding process`);
// This will trigger a new transcoding process with seek
}
// Update video source with seek parameter
const seekUrl = `/api/stream/${videoId}/transcode?seek=${newTime}`;
videoRef.current.src = seekUrl;
videoRef.current.load();
// Set current time immediately for UI responsiveness
setCurrentTime(newTime);
// Start new transcoding from seek point
setIsTranscoding(true);
}
};
2.2 Process Monitoring
// hooks/use-ffmpeg-monitor.ts
export const useFFmpegMonitor = (videoId: string) => {
const [processInfo, setProcessInfo] = useState<any[]>([]);
useEffect(() => {
const interval = setInterval(async () => {
try {
const response = await fetch(`/api/ffmpeg/status?videoId=${videoId}`);
const data = await response.json();
setProcessInfo(data.processes);
} catch (error) {
console.error('Failed to fetch FFmpeg status:', error);
}
}, 2000);
return () => clearInterval(interval);
}, [videoId]);
return processInfo;
};
Phase 3: Advanced Features
3.1 Preemptive Process Management
// lib/ffmpeg/preemptive-manager.ts
class PreemptiveFFmpegManager {
// Start transcoding slightly ahead of current playback
startPreemptiveTranscoding(videoId: string, currentTime: number) {
const aheadTime = currentTime + 30; // 30 seconds ahead
// Start background process
const ffmpeg = spawn('ffmpeg', [
'-ss', aheadTime.toString(),
'-i', videoPath,
// ... other args
]);
// Register as preemptive (lower priority)
ffmpegRegistry.register(videoId, aheadTime, ffmpeg, [], { preemptive: true });
}
}
3.2 Quality Adaptation
// lib/ffmpeg/quality-manager.ts
class QualityManager {
// Adapt quality based on system load
getOptimalQuality(systemLoad: number, availableMemory: number) {
if (systemLoad > 0.8 || availableMemory < 1000000000) {
return { crf: 28, preset: 'ultrafast' }; // Lower quality, faster
} else if (systemLoad > 0.5) {
return { crf: 25, preset: 'veryfast' }; // Medium quality
} else {
return { crf: 23, preset: 'fast' }; // Higher quality
}
}
}
📋 Implementation Plan
Week 1: Foundation
- Implement
FFmpegProcessRegistryclass - Add seek parameter to transcoding API
- Basic process cleanup on client disconnect
Week 2: Integration
- Update video player to use seek-optimized transcoding
- Implement process monitoring in frontend
- Add process status API endpoint
Week 3: Advanced Features
- Preemptive transcoding for smooth playback
- Quality adaptation based on system load
- Process health monitoring and auto-cleanup
Week 4: Testing & Optimization
- Load testing with multiple concurrent streams
- Memory leak detection and prevention
- Performance optimization
🎯 Expected Results
Before (Current Issues)
- ❌ Seeking requires transcoding from beginning
- ❌ FFmpeg processes may leak resources
- ❌ Poor performance on seek operations
- ❌ Complex duration protection (unnecessary)
After (Stash-like Solution)
- ✅ Instant seeking with
--ssparameter - ✅ Clean process management - one process per seek
- ✅ Resource efficiency - no unnecessary buffering
- ✅ Simple architecture - focus on process management, not duration protection
- ✅ Professional streaming experience like Stash
🔧 Key Implementation Details
1. Process Lifecycle
Seek Request → Kill Old Process → Start New Process with -ss → Stream Response
2. Resource Management
- Memory: Kill old processes immediately
- CPU: One active process per video
- Network: Direct streaming without buffering
3. Error Handling
- Process failures: Auto-restart with exponential backoff
- Network issues: Kill process, let client retry
- System overload: Reduce quality, limit concurrent processes
🚀 Why This Approach is Better
- 🎯 Solves Real Problem: Process management, not duration corruption
- 🔄 Follows Stash Pattern: Proven approach from successful application
- ⚡ Performance: Instant seeking, no unnecessary transcoding
- 🧹 Clean Architecture: Simple, maintainable code
- 📈 Scalable: Easy to add features like quality adaptation
This approach transforms our video player from a basic transcoder into a professional streaming solution that rivals Stash's performance and reliability.
🎨 UI Issues That Still Need Solving
The Remaining Problems
Even with solid FFmpeg process management, we still have these critical UI issues:
- ❌ Progress Bar Jumping Backwards: During buffering, the progress bar jumps backward as new data arrives
- ❌ Wrong Duration Display: Shows buffered duration instead of real video duration
- ❌ Poor User Experience: Users can't trust the progress bar or seek to accurate positions
Why These Issues Persist
These UI issues are separate from the FFmpeg process management problem:
- FFmpeg Process Management: Solves seeking performance and resource leaks
- UI Progress Issues: Caused by how the browser handles video metadata and buffering events
Focused UI Solution Strategy
1. Duration Source Priority
// Priority order for duration (highest to lowest)
const getDuration = async (videoId: string) => {
// 1. Database-stored duration (most reliable)
const dbDuration = await getStoredDuration(videoId);
if (dbDuration > 0) return dbDuration;
// 2. HTTP headers from transcoding endpoint
const headerDuration = await getHeaderDuration(videoId);
if (headerDuration > 0) return headerDuration;
// 3. Video element metadata (least reliable - can be buffered duration)
return null; // Let video element handle it
};
2. Progress Bar Anti-Jitter
// Simple, effective anti-jitter logic
const useStableProgress = (videoRef: RefObject<HTMLVideoElement>, realDuration: number) => {
const [currentTime, setCurrentTime] = useState(0);
const lastStableTime = useRef(0);
const handleTimeUpdate = () => {
if (!videoRef.current) return;
const newTime = videoRef.current.currentTime;
// Only allow forward progress or very small backward adjustments
if (newTime >= lastStableTime.current - 0.1) {
setCurrentTime(newTime);
lastStableTime.current = newTime;
} else {
console.log(`[PROGRESS] Blocked backward jump: ${newTime}s -> ${lastStableTime.current}s`);
}
};
return { currentTime, handleTimeUpdate };
};
3. Duration Protection
// Protect against buffered duration corruption
const useProtectedDuration = (videoId: string) => {
const [duration, setDuration] = useState(0);
const hasRealDuration = useRef(false);
useEffect(() => {
// Get real duration from database first
const fetchRealDuration = async () => {
const realDuration = await getDuration(videoId);
if (realDuration > 0) {
setDuration(realDuration);
hasRealDuration.current = true;
console.log(`[DURATION] Set real duration: ${realDuration}s`);
}
};
fetchRealDuration();
}, [videoId]);
// Block duration updates from video metadata if we have real duration
const handleDurationChange = (newDuration: number) => {
if (hasRealDuration.current) {
console.log(`[DURATION] Blocked metadata duration: ${newDuration}s (using stored: ${duration}s)`);
return; // Keep the real duration
}
// Only accept duration if it's significantly larger (not buffered duration)
if (newDuration > duration * 2.0) {
setDuration(newDuration);
console.log(`[DURATION] Updated duration: ${newDuration}s`);
}
};
return { duration, handleDurationChange };
};
Implementation Priority
- 🔥 High Priority: Fix duration display (show real duration, not buffered)
- 🔥 High Priority: Fix progress bar backward jumps
- ⚡ Medium Priority: Integrate with FFmpeg process management
- 🎨 Low Priority: Enhanced progress bar UI (buffer visualization, etc.)
Why This Approach Works
- 🎯 Focused: Solves specific UI problems without overcomplicating
- 🔄 Compatible: Works alongside the FFmpeg process management solution
- ⚡ Simple: Uses proven patterns (database-first duration, anti-jitter logic)
- 🧪 Testable: Each component can be tested independently
Expected UI Results
- ✅ Accurate Duration: Always shows real video duration (e.g., 9 minutes, not 6 seconds)
- ✅ Stable Progress: No more backward jumps during buffering
- ✅ Reliable Seeking: Seek bar represents actual video timeline
- ✅ Professional Feel: Progress bar behaves like YouTube/Netflix