457 lines
15 KiB
Markdown
457 lines
15 KiB
Markdown
# 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:
|
|
|
|
1. **❌ Poor FFmpeg Process Management**: Our current system doesn't handle FFmpeg processes robustly
|
|
2. **❌ No Seek-Optimized Transcoding**: We don't restart FFmpeg with `--ss` for seeking like Stash does
|
|
3. **❌ Resource Leaks**: FFmpeg processes may not terminate properly, causing system issues
|
|
4. **❌ 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**
|
|
|
|
1. **🎯 Instant Seeking**: No need to wait for transcoding from beginning
|
|
2. **🔄 Clean Process Management**: Each seek = new process, old process can be killed
|
|
3. **💾 Memory Efficiency**: No need to buffer from start to seek point
|
|
4. **⚡ Performance**: Direct start at desired position
|
|
5. **🧹 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**
|
|
```typescript
|
|
// 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**
|
|
```typescript
|
|
// 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**
|
|
```typescript
|
|
// 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**
|
|
```typescript
|
|
// 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**
|
|
```typescript
|
|
// 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**
|
|
```typescript
|
|
// 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 `FFmpegProcessRegistry` class
|
|
- [ ] 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 `--ss` parameter
|
|
- ✅ **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**
|
|
|
|
1. **🎯 Solves Real Problem**: Process management, not duration corruption
|
|
2. **🔄 Follows Stash Pattern**: Proven approach from successful application
|
|
3. **⚡ Performance**: Instant seeking, no unnecessary transcoding
|
|
4. **🧹 Clean Architecture**: Simple, maintainable code
|
|
5. **📈 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:
|
|
|
|
1. **❌ Progress Bar Jumping Backwards**: During buffering, the progress bar jumps backward as new data arrives
|
|
2. **❌ Wrong Duration Display**: Shows buffered duration instead of real video duration
|
|
3. **❌ 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**
|
|
```typescript
|
|
// 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**
|
|
```typescript
|
|
// 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**
|
|
```typescript
|
|
// 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**
|
|
|
|
1. **🔥 High Priority**: Fix duration display (show real duration, not buffered)
|
|
2. **🔥 High Priority**: Fix progress bar backward jumps
|
|
3. **⚡ Medium Priority**: Integrate with FFmpeg process management
|
|
4. **🎨 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
|