nextav/docs/archive/transcoding-legacy/STASH-ANALYSIS-AND-SOLUTION...

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