Compare commits
No commits in common. "a22e4a95c537808e11dfcc8b10048fb96c81e0c0" and "b93bd268259afe19c890b1d1d7b43c1ccd658812" have entirely different histories.
a22e4a95c5
...
b93bd26825
BIN
data/media.db
BIN
data/media.db
Binary file not shown.
|
|
@ -1,358 +0,0 @@
|
|||
# Anti-Jitter Progress System Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the implementation of Stash's anti-jitter mechanisms in NextAV to solve the streaming buffer jitter problem where progress bars jump backward as new data arrives.
|
||||
|
||||
## Problem Solved
|
||||
|
||||
**Before**: The progress bar would jump backward when:
|
||||
- Buffer underruns occurred
|
||||
- Network delays caused time to "catch up"
|
||||
- Raw video time updates were directly reflected in the UI
|
||||
|
||||
**After**: Smooth, consistent progress that:
|
||||
- Prevents backward jumps beyond a threshold
|
||||
- Throttles updates to prevent excessive re-renders
|
||||
- Allows small backward adjustments for smooth playback
|
||||
- Provides visual buffer state feedback
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Custom Hook: `useAntiJitterProgress`
|
||||
|
||||
**Location**: `src/lib/use-anti-jitter-progress.ts`
|
||||
|
||||
**Key Features**:
|
||||
- **Jitter Threshold**: 0.5 seconds - maximum allowed backward jump
|
||||
- **Update Throttling**: 100ms minimum between updates
|
||||
- **Progress Reference**: Maintains stable progress state
|
||||
- **Buffer Monitoring**: Tracks actual buffer state
|
||||
|
||||
**Core Logic**:
|
||||
```typescript
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
const now = Date.now();
|
||||
const video = videoRef.current;
|
||||
const rawTime = video.currentTime;
|
||||
const currentProgress = progressRef.current;
|
||||
|
||||
// Prevent backward jumps beyond threshold
|
||||
if (rawTime < currentProgress - jitterThreshold) {
|
||||
console.log(`[ANTI-JITTER] Blocked backward jump: ${rawTime}s -> ${currentProgress}s`);
|
||||
return; // Block this update
|
||||
}
|
||||
|
||||
// Throttle updates to prevent excessive re-renders
|
||||
if (now - lastUpdateRef.current < updateThrottle) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and update progress
|
||||
if (rawTime >= 0 && rawTime <= (duration || Infinity)) {
|
||||
if (rawTime >= currentProgress - 0.1) {
|
||||
// Forward progress or small adjustment
|
||||
progressRef.current = rawTime;
|
||||
setCurrentTime(rawTime);
|
||||
lastUpdateRef.current = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Enhanced Progress Bar
|
||||
|
||||
**Features**:
|
||||
- **Buffer Visualization**: Blue overlay shows buffered content
|
||||
- **Smooth Progress**: Blue bar shows current playback position
|
||||
- **Seek Overlay**: Invisible range input for seeking
|
||||
- **Buffer Status**: Text display of buffered duration
|
||||
|
||||
**Visual Elements**:
|
||||
```tsx
|
||||
<div className="relative w-full h-2 bg-gray-600 rounded-lg overflow-hidden">
|
||||
{/* Buffer indicator */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-400/30 rounded-lg"
|
||||
style={{
|
||||
width: `${Math.min((bufferState.buffered / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-500 rounded-lg transition-all duration-100"
|
||||
style={{
|
||||
width: `${Math.min((currentTime / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
{/* Seek input overlay */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Anti-Jitter Mechanisms
|
||||
|
||||
#### A. Backward Jump Prevention
|
||||
- **Threshold**: 0.5 seconds maximum backward jump
|
||||
- **Logic**: Blocks updates that would cause large backward movement
|
||||
- **Benefit**: Prevents jarring progress bar jumps
|
||||
|
||||
#### B. Update Throttling
|
||||
- **Frequency**: Maximum 10 updates per second (100ms throttle)
|
||||
- **Logic**: Skips updates that come too quickly
|
||||
- **Benefit**: Smoother UI performance, less CPU usage
|
||||
|
||||
#### C. Small Adjustment Allowance
|
||||
- **Threshold**: 0.1 seconds for small backward adjustments
|
||||
- **Logic**: Allows minor corrections for smooth playback
|
||||
- **Benefit**: Maintains playback quality while preventing large jumps
|
||||
|
||||
#### D. Progress Reference Management
|
||||
- **State**: Maintains stable progress reference
|
||||
- **Updates**: Only updates when progress is valid
|
||||
- **Reset**: Resets on video source changes
|
||||
|
||||
### 4. Buffer State Monitoring
|
||||
|
||||
**Features**:
|
||||
- **Real-time Tracking**: Monitors `progress` events
|
||||
- **Visual Feedback**: Shows buffered content in progress bar
|
||||
- **Status Display**: Shows buffered duration below progress bar
|
||||
|
||||
**Implementation**:
|
||||
```typescript
|
||||
const handleProgress = () => {
|
||||
if (videoRef.current) {
|
||||
const video = videoRef.current;
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
bufferStateRef.current = {
|
||||
buffered: bufferedEnd,
|
||||
lastBufferUpdate: Date.now()
|
||||
};
|
||||
console.log(`[BUFFER] Buffered to ${bufferedEnd}s`);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. In Video Components
|
||||
|
||||
```typescript
|
||||
import { useAntiJitterProgress } from '@/lib/use-anti-jitter-progress';
|
||||
|
||||
export default function VideoViewer({ video, isOpen, onClose }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Use the anti-jitter progress hook
|
||||
const {
|
||||
currentTime,
|
||||
bufferState,
|
||||
handleTimeUpdate,
|
||||
handleProgress,
|
||||
seekTo,
|
||||
resetProgress
|
||||
} = useAntiJitterProgress(videoRef, duration);
|
||||
|
||||
// Add event listeners
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.addEventListener('timeupdate', handleTimeUpdate);
|
||||
videoRef.current.addEventListener('progress', handleProgress);
|
||||
|
||||
return () => {
|
||||
videoRef.current?.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
videoRef.current?.removeEventListener('progress', handleProgress);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle seeking
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = parseFloat(e.target.value);
|
||||
seekTo(newTime);
|
||||
};
|
||||
|
||||
// Reset on video change
|
||||
useEffect(() => {
|
||||
resetProgress();
|
||||
}, [video]);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Enhanced Progress Bar
|
||||
|
||||
```tsx
|
||||
{/* Enhanced Progress bar with buffer visualization */}
|
||||
<div className="mb-4">
|
||||
<div className="relative w-full h-2 bg-gray-600 rounded-lg overflow-hidden">
|
||||
{/* Buffer indicator */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-400/30 rounded-lg"
|
||||
style={{
|
||||
width: `${Math.min((bufferState.buffered / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-500 rounded-lg transition-all duration-100"
|
||||
style={{
|
||||
width: `${Math.min((currentTime / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
{/* Seek input overlay */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-white text-sm mt-1">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
{/* Buffer status */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div className="text-xs text-blue-300 mt-1">
|
||||
Buffered: {formatTime(bufferState.buffered)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Jitter Threshold
|
||||
```typescript
|
||||
const jitterThreshold = 0.5; // Maximum allowed backward jump in seconds
|
||||
```
|
||||
- **Lower values**: More strict, less jitter but potentially choppy
|
||||
- **Higher values**: More lenient, smoother but potential for jumps
|
||||
- **Recommended**: 0.5 seconds (good balance)
|
||||
|
||||
### Update Throttle
|
||||
```typescript
|
||||
const updateThrottle = 100; // Minimum ms between updates
|
||||
```
|
||||
- **Lower values**: More responsive but higher CPU usage
|
||||
- **Higher values**: Smoother but less responsive
|
||||
- **Recommended**: 100ms (10 FPS, good balance)
|
||||
|
||||
### Small Adjustment Threshold
|
||||
```typescript
|
||||
if (rawTime >= currentProgress - 0.1) // 0.1 seconds
|
||||
```
|
||||
- **Lower values**: Stricter progress validation
|
||||
- **Higher values**: More lenient with small adjustments
|
||||
- **Recommended**: 0.1 seconds (allows smooth playback)
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Script
|
||||
Run the test script to verify the anti-jitter system:
|
||||
```bash
|
||||
node test-anti-jitter.mjs
|
||||
```
|
||||
|
||||
### Test Scenarios
|
||||
1. **Normal Forward Progress**: All forward updates should pass
|
||||
2. **Small Backward Adjustment**: Small adjustments should pass
|
||||
3. **Large Backward Jump**: Large backward jumps should be blocked
|
||||
4. **Rapid Updates**: Rapid updates should be throttled
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. User Experience
|
||||
- **Smooth Progress**: No more jarring backward jumps
|
||||
- **Visual Feedback**: Clear buffer state indication
|
||||
- **Consistent Behavior**: Predictable progress bar movement
|
||||
|
||||
### 2. Performance
|
||||
- **Reduced Re-renders**: Throttled updates improve performance
|
||||
- **Stable State**: Progress reference prevents unnecessary updates
|
||||
- **Efficient Monitoring**: Smart event handling
|
||||
|
||||
### 3. Reliability
|
||||
- **Jitter Prevention**: Blocks problematic time updates
|
||||
- **Buffer Awareness**: Tracks actual buffered content
|
||||
- **Error Handling**: Graceful fallbacks for edge cases
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 1. Adaptive Thresholds
|
||||
- **Dynamic Jitter Threshold**: Adjust based on video quality
|
||||
- **Network-Aware Throttling**: Adapt to connection speed
|
||||
- **Quality-Based Settings**: Different thresholds for different scenarios
|
||||
|
||||
### 2. Advanced Buffer Management
|
||||
- **Predictive Buffering**: Anticipate buffer needs
|
||||
- **Quality Adaptation**: Switch quality based on buffer state
|
||||
- **Network Monitoring**: Track connection health
|
||||
|
||||
### 3. Enhanced Visualization
|
||||
- **Buffer Prediction**: Show predicted buffer state
|
||||
- **Quality Indicators**: Visual quality level indicators
|
||||
- **Network Status**: Connection health indicators
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Progress Bar Not Moving
|
||||
- Check if `handleTimeUpdate` is being called
|
||||
- Verify `jitterThreshold` isn't too restrictive
|
||||
- Ensure video element has valid duration
|
||||
|
||||
#### 2. Excessive Throttling
|
||||
- Increase `updateThrottle` value
|
||||
- Check for rapid timeupdate events
|
||||
- Verify video source stability
|
||||
|
||||
#### 3. Buffer Not Showing
|
||||
- Ensure `handleProgress` is attached to `progress` event
|
||||
- Check if video has buffered ranges
|
||||
- Verify buffer state updates
|
||||
|
||||
### Debug Logging
|
||||
The system provides comprehensive logging:
|
||||
```
|
||||
[ANTI-JITTER] Blocked backward jump: 2s -> 5s
|
||||
[THROTTLE] Skipped update: 50ms < 100ms
|
||||
[BUFFER] Buffered to 10s
|
||||
[SEEK] Seeking to 15s, updated progress reference
|
||||
[PROGRESS] Forward progress: 16s
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The anti-jitter progress system successfully implements Stash's approach to solve streaming buffer jitter. By preventing backward jumps, throttling updates, and providing visual feedback, it creates a smooth, professional video playback experience.
|
||||
|
||||
The system is:
|
||||
- **Configurable**: Easy to adjust thresholds and behavior
|
||||
- **Reusable**: Shared hook for multiple components
|
||||
- **Efficient**: Minimal performance impact
|
||||
- **Reliable**: Handles edge cases gracefully
|
||||
|
||||
This implementation provides the foundation for professional-grade video streaming without the jittery behavior common in basic implementations.
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
# UI Implementation Plan: Duration and Progress Bar Fixes
|
||||
|
||||
## 🎯 **Goal**
|
||||
Fix two critical UI issues:
|
||||
1. Duration showing buffered duration instead of real video duration
|
||||
2. Progress bar jumping backwards during buffering
|
||||
|
||||
## 🔍 **Root Cause**
|
||||
- **Duration Issue**: Video metadata events fire before real duration is loaded
|
||||
- **Progress Issue**: Browser buffering reports backward timestamps
|
||||
|
||||
## 🏗️ **Solution Strategy**
|
||||
|
||||
### **1. Duration Protection Hook**
|
||||
```typescript
|
||||
// hooks/use-protected-duration.ts
|
||||
export const useProtectedDuration = (videoId: string) => {
|
||||
const [duration, setDuration] = useState(0);
|
||||
const hasRealDuration = useRef(false);
|
||||
|
||||
// Priority: Database > Headers > Video Metadata
|
||||
const fetchRealDuration = async () => {
|
||||
// 1. Try database first
|
||||
const dbDuration = await getStoredDuration(videoId);
|
||||
if (dbDuration > 0) {
|
||||
setDuration(dbDuration);
|
||||
hasRealDuration.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Try transcoding headers
|
||||
const headerDuration = await getHeaderDuration(videoId);
|
||||
if (headerDuration > 0) {
|
||||
setDuration(headerDuration);
|
||||
hasRealDuration.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Block metadata duration if we have real duration
|
||||
const handleDurationChange = (newDuration: number) => {
|
||||
if (hasRealDuration.current) {
|
||||
return; // Keep real duration
|
||||
}
|
||||
// Only accept significantly larger durations
|
||||
if (newDuration > duration * 2.0) {
|
||||
setDuration(newDuration);
|
||||
}
|
||||
};
|
||||
|
||||
return { duration, handleDurationChange };
|
||||
};
|
||||
```
|
||||
|
||||
### **2. Stable Progress Hook**
|
||||
```typescript
|
||||
// hooks/use-stable-progress.ts
|
||||
export const useStableProgress = (videoRef: RefObject<HTMLVideoElement>) => {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const lastStableTime = useRef(0);
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const newTime = videoRef.current.currentTime;
|
||||
|
||||
// Prevent backward jumps
|
||||
if (newTime < lastStableTime.current - 0.1) {
|
||||
console.log(`[PROGRESS] Blocked backward jump: ${newTime}s -> ${lastStableTime.current}s`);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentTime(newTime);
|
||||
lastStableTime.current = newTime;
|
||||
};
|
||||
|
||||
return { currentTime, handleTimeUpdate };
|
||||
};
|
||||
```
|
||||
|
||||
## 📋 **Implementation Steps**
|
||||
|
||||
### **Day 1: Create Hooks**
|
||||
- [ ] Create `use-protected-duration.ts`
|
||||
- [ ] Create `use-stable-progress.ts`
|
||||
- [ ] Test hooks independently
|
||||
|
||||
### **Day 2: Update Video Viewer**
|
||||
- [ ] Remove old duration/progress logic
|
||||
- [ ] Integrate new hooks
|
||||
- [ ] Test duration protection
|
||||
|
||||
### **Day 3: Test and Debug**
|
||||
- [ ] Test with direct videos
|
||||
- [ ] Test with transcoded streams
|
||||
- [ ] Verify no backward jumps
|
||||
|
||||
## 🎯 **Expected Results**
|
||||
- ✅ Duration always shows real video length (9 min, not 6 sec)
|
||||
- ✅ Progress bar never jumps backward
|
||||
- ✅ Professional streaming experience
|
||||
- ✅ Simple, maintainable code
|
||||
|
||||
## 🚀 **Next Steps**
|
||||
After UI fixes, implement FFmpeg process management for seek optimization.
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
ffprobe
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 2:50 stash
|
||||
260 root 0:00 /bin/sh
|
||||
339 root 0:33 /usr/bin/ffmpeg -hide_banner -v error -i /mnt/thd_media_f/Porn/分类合集精选/KINGMASTER/
|
||||
370 root 0:00 ps aux
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 2:50 stash
|
||||
260 root 0:00 /bin/sh
|
||||
371 root 0:11 /usr/bin/ffmpeg -hide_banner -v error -ss 93.56497005988024 -i /mnt/thd_media_f/Porn/分类
|
||||
402 root 0:00 ps aux
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 2:50 stash
|
||||
260 root 0:00 /bin/sh
|
||||
403 root 0:13 /usr/bin/ffmpeg -hide_banner -v error -ss 129.2849301397206 -i /mnt/thd_media_f/Porn/分类
|
||||
434 root 0:00 ps aux
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 2:50 stash
|
||||
260 root 0:00 /bin/sh
|
||||
435 root 0:07 /usr/bin/ffmpeg -hide_banner -v error -ss 183.66457085828344 -i /mnt/thd_media_f/Porn/分类
|
||||
466 root 0:00 ps aux
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 2:50 stash
|
||||
260 root 0:00 /bin/sh
|
||||
467 root 0:09 /usr/bin/ffmpeg -hide_banner -v error -ss 223.11646706586828 -i /mnt/thd_media_f/Porn/分类
|
||||
498 root 0:00 ps aux
|
||||
|
||||
|
||||
|
||||
----------more detail---
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 3:06 stash
|
||||
260 root 0:00 /bin/sh
|
||||
559 root 0:43 /usr/bin/ffmpeg -hide_banner -v error -ss 29.848541666666662 -i /mnt/thd_media_f/Porn/
|
||||
706 root 0:00 ps aux
|
||||
/usr/bin # cat /proc/503/cmdline | tr '\0' ' '
|
||||
cat: can't open '/proc/503/cmdline': No such file or directory
|
||||
/usr/bin # cat /proc/559/cmdline | tr '\0' ' '
|
||||
/usr/bin/ffmpeg -hide_banner -v error -ss 29.848541666666662 -i /mnt/thd_media_f/Porn/分类合集精选/uuuuuuuu.avi -c:v libx264 -pix_fmt yuv420p -preset veryfast -crf 25 -sc_threshold 0 -movflags frag_keyframe+empty_moov -ac 2 -f mp4 pipe: /usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 3:06 stash
|
||||
260 root 0:00 /bin/sh
|
||||
711 root 0:08 /usr/bin/ffmpeg -hide_banner -v error -ss 77.374375 -i /mnt/thd_media_f/Porn/分类合
|
||||
738 root 0:00 ps aux
|
||||
/usr/bin # cat /proc/711/cmdline | tr '\0' ' '
|
||||
/usr/bin/ffmpeg -hide_banner -v error -ss 77.374375 -i /mnt/thd_media_f/Porn/分类合集精选/uuuuuuuu.avi -c:v libx264 -pix_fmt yuv420p -preset veryfast -crf 25 -sc_threshold 0 -movflags frag_keyframe+empty_moov -ac 2 -f mp4 pipe: /usr/bin #
|
||||
/usr/bin # ps aux
|
||||
PID USER TIME COMMAND
|
||||
1 root 3:06 stash
|
||||
260 root 0:00 /bin/sh
|
||||
741 root 0:07 /usr/bin/ffmpeg -hide_banner -v error -ss 103.31072916666666 -i /mnt/thd_media_f/Porn/
|
||||
768 root 0:00 ps aux
|
||||
/usr/bin # cat /proc/741/cmdline | tr '\0' ' '
|
||||
/usr/bin/ffmpeg -hide_banner -v error -ss 103.31072916666666 -i /mnt/thd_media_f/Porn/分类合集精选/uuuuuuuu.avi -c:v libx264 -pix_fmt yuv420p -preset veryfast -crf 25 -sc_threshold 0 -movflags frag_keyframe+empty_moov -ac 2 -f mp4 pipe: /usr/bin #
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry';
|
||||
|
||||
/**
|
||||
* GET /api/ffmpeg/status
|
||||
*
|
||||
* Returns the current status of all FFmpeg processes.
|
||||
*
|
||||
* Query parameters:
|
||||
* - videoId: Filter processes by video ID
|
||||
* - includeStats: Include additional statistics (default: false)
|
||||
* - format: Response format - 'json' or 'table' (default: 'json')
|
||||
*
|
||||
* Response format:
|
||||
* {
|
||||
* "totalProcesses": 3,
|
||||
* "activeProcesses": [...],
|
||||
* "stats": {
|
||||
* "totalUptime": 12345,
|
||||
* "averageSeekTime": 120.5,
|
||||
* "mostActiveVideo": "video_123"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const videoId = searchParams.get('videoId');
|
||||
const includeStats = searchParams.get('includeStats') === 'true';
|
||||
const format = searchParams.get('format') || 'json';
|
||||
|
||||
let processes;
|
||||
if (videoId) {
|
||||
processes = ffmpegRegistry.getProcessesForVideo(videoId);
|
||||
} else {
|
||||
processes = ffmpegRegistry.getAllProcesses();
|
||||
}
|
||||
|
||||
const totalProcesses = ffmpegRegistry.getTotalProcessCount();
|
||||
|
||||
if (format === 'table') {
|
||||
// Return a simple text table format for debugging
|
||||
const table = formatAsTable(processes);
|
||||
return new Response(table, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const response: any = {
|
||||
totalProcesses,
|
||||
activeProcesses: processes,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (includeStats) {
|
||||
response.stats = calculateStats(processes);
|
||||
}
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('FFmpeg status API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ffmpeg/status
|
||||
*
|
||||
* Cleanup FFmpeg processes
|
||||
* Query parameters:
|
||||
* - videoId: Kill all processes for specific video ID
|
||||
* - stale: Kill stale processes older than maxAge (default: false)
|
||||
* - maxAge: Maximum age in milliseconds for stale cleanup (default: 10min)
|
||||
* - all: Kill all processes (default: false)
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const videoId = searchParams.get('videoId');
|
||||
const stale = searchParams.get('stale') === 'true';
|
||||
const all = searchParams.get('all') === 'true';
|
||||
const maxAge = parseInt(searchParams.get('maxAge') || '600000'); // 10 minutes
|
||||
|
||||
let killedCount = 0;
|
||||
|
||||
if (all) {
|
||||
killedCount = ffmpegRegistry.cleanupAll();
|
||||
} else if (videoId) {
|
||||
killedCount = ffmpegRegistry.killAllForVideo(videoId);
|
||||
} else if (stale) {
|
||||
killedCount = ffmpegRegistry.cleanupStaleProcesses(maxAge);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
killedCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('FFmpeg cleanup API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ffmpeg/status
|
||||
*
|
||||
* Trigger cleanup operations
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { operation, videoId, maxAge } = body;
|
||||
|
||||
let result;
|
||||
|
||||
switch (operation) {
|
||||
case 'cleanup':
|
||||
result = ffmpegRegistry.cleanupStaleProcesses(maxAge || 600000);
|
||||
break;
|
||||
case 'killAll':
|
||||
result = ffmpegRegistry.cleanupAll();
|
||||
break;
|
||||
case 'killVideo':
|
||||
if (!videoId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'videoId is required for killVideo operation' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
result = ffmpegRegistry.killAllForVideo(videoId);
|
||||
break;
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid operation. Use: cleanup, killAll, or killVideo' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result,
|
||||
operation,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('FFmpeg operation API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS handler for CORS
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate statistics for active processes
|
||||
*/
|
||||
function calculateStats(processes: any[]) {
|
||||
if (processes.length === 0) {
|
||||
return {
|
||||
totalUptime: 0,
|
||||
averageSeekTime: 0,
|
||||
mostActiveVideo: null,
|
||||
processCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const totalUptime = processes.reduce((sum, p) => sum + p.uptime, 0);
|
||||
const averageSeekTime = processes.reduce((sum, p) => sum + p.seekTime, 0) / processes.length;
|
||||
|
||||
// Find most active video
|
||||
const videoCounts = processes.reduce((acc, p) => {
|
||||
acc[p.videoId] = (acc[p.videoId] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const mostActiveVideo = Object.entries(videoCounts)
|
||||
.sort(([, a], [, b]) => (b as number) - (a as number))[0]?.[0] || null;
|
||||
|
||||
return {
|
||||
totalUptime,
|
||||
averageSeekTime,
|
||||
mostActiveVideo,
|
||||
processCount: processes.length,
|
||||
videoCounts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format processes as a simple text table for debugging
|
||||
*/
|
||||
function formatAsTable(processes: any[]): string {
|
||||
if (processes.length === 0) {
|
||||
return 'No active FFmpeg processes';
|
||||
}
|
||||
|
||||
const header = 'VIDEO ID'.padEnd(10) + 'SEEK'.padEnd(8) + 'UPTIME'.padEnd(12) + 'QUALITY'.padEnd(8);
|
||||
const separator = '-'.repeat(header.length);
|
||||
|
||||
const rows = processes.map(p => {
|
||||
const uptime = formatUptime(p.uptime);
|
||||
const seek = p.seekTime.toFixed(1).padStart(6) + 's';
|
||||
const quality = (p.quality || 'default').padEnd(7);
|
||||
return p.videoId.padEnd(10) + seek.padEnd(8) + uptime.padEnd(12) + quality;
|
||||
});
|
||||
|
||||
return [header, separator, ...rows].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format uptime in a human-readable way
|
||||
*/
|
||||
function formatUptime(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +1,9 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDatabase } from '@/db';
|
||||
import fs from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { Readable } from 'stream';
|
||||
import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry';
|
||||
|
||||
// Track active requests to prevent duplicate processing
|
||||
const activeRequests = new Map<string, Promise<Response>>();
|
||||
|
||||
export async function HEAD(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// Handle HEAD requests by returning just headers without body
|
||||
try {
|
||||
const { id } = await params;
|
||||
const db = getDatabase();
|
||||
|
||||
const media = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(id, 'video') as { path: string, codec_info: string } | undefined;
|
||||
if (!media) {
|
||||
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get duration from stored codec_info
|
||||
let duration = 0;
|
||||
try {
|
||||
const codecInfo = JSON.parse(media.codec_info || '{}');
|
||||
duration = codecInfo.duration || 0;
|
||||
} catch (error) {
|
||||
// Skip ffprobe fallback for HEAD requests
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const seekTime = parseFloat(searchParams.get('seek') || '0');
|
||||
|
||||
const headers = new Headers({
|
||||
'Content-Type': 'video/mp4',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'X-Content-Duration': duration.toString(),
|
||||
'X-Seek-Time': seekTime.toString(),
|
||||
'X-Transcoded': 'true',
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('HEAD request error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
import { processManager } from '@/lib/process-manager';
|
||||
|
||||
export async function OPTIONS(
|
||||
request: NextRequest,
|
||||
|
|
@ -79,6 +28,8 @@ export async function GET(
|
|||
const { id } = await params;
|
||||
const db = getDatabase();
|
||||
|
||||
console.log(`[TRANSCODE] Starting transcoding for video ID: ${id}`);
|
||||
|
||||
// Get media file info with codec_info
|
||||
const media = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(id, 'video') as { path: string, codec_info: string } | undefined;
|
||||
if (!media) {
|
||||
|
|
@ -97,7 +48,19 @@ export async function GET(
|
|||
console.log(`[TRANSCODE] Using stored duration: ${duration}s`);
|
||||
} catch (error) {
|
||||
console.error(`[TRANSCODE] Could not parse codec_info:`, error);
|
||||
console.log(`[TRANSCODE] Using default duration: 0s`);
|
||||
// Fallback to ffprobe
|
||||
try {
|
||||
const videoInfo = await new Promise<any>((resolve, reject) => {
|
||||
ffmpeg.ffprobe(filePath, (err, metadata) => {
|
||||
if (err) reject(err);
|
||||
else resolve(metadata);
|
||||
});
|
||||
});
|
||||
duration = videoInfo.format.duration || 0;
|
||||
console.log(`[TRANSCODE] Using ffprobe duration: ${duration}s`);
|
||||
} catch (ffprobeError) {
|
||||
console.error(`[TRANSCODE] Could not get duration:`, ffprobeError);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
|
|
@ -105,13 +68,9 @@ export async function GET(
|
|||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get parameters
|
||||
// Get quality parameter
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const quality = searchParams.get('quality') || '720p';
|
||||
const seek = searchParams.get('seek') || '0';
|
||||
const retry = searchParams.get('retry') || '0';
|
||||
const seekTime = parseFloat(seek);
|
||||
const retryCount = parseInt(retry);
|
||||
|
||||
// Configure transcoding based on quality
|
||||
const qualitySettings = {
|
||||
|
|
@ -122,94 +81,16 @@ export async function GET(
|
|||
|
||||
const settings = qualitySettings[quality as keyof typeof qualitySettings] || qualitySettings['720p'];
|
||||
|
||||
console.log(`[TRANSCODE] Starting transcoding for video ID: ${id}, seek: ${seekTime}s, quality: ${quality}, retry: ${retryCount}`);
|
||||
|
||||
// Create a unique request key for deduplication (without timestamp to allow reuse)
|
||||
const requestKey = `${id}_${seekTime}_${quality}`;
|
||||
|
||||
// Check if there's already an active request for this exact configuration
|
||||
if (activeRequests.has(requestKey)) {
|
||||
console.log(`[TRANSCODE] Reusing active request for ${requestKey}`);
|
||||
return activeRequests.get(requestKey)!;
|
||||
}
|
||||
|
||||
// Create the transcoding promise
|
||||
const transcodePromise = createTranscodeStream(id, filePath, seekTime, quality, duration, settings);
|
||||
|
||||
// Store the promise to prevent duplicate requests
|
||||
activeRequests.set(requestKey, transcodePromise);
|
||||
|
||||
// Clean up the request tracking after completion
|
||||
transcodePromise.finally(() => {
|
||||
setTimeout(() => {
|
||||
activeRequests.delete(requestKey);
|
||||
console.log(`[TRANSCODE] Cleaned up active request: ${requestKey}`);
|
||||
}, 5000); // 5 seconds delay to allow for quick retries
|
||||
});
|
||||
|
||||
return transcodePromise;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Transcoding API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Separate function to handle the actual transcoding
|
||||
async function createTranscodeStream(
|
||||
id: string,
|
||||
filePath: string,
|
||||
seekTime: number,
|
||||
quality: string,
|
||||
duration: number,
|
||||
settings: { width: number, height: number, bitrate: string }
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// STASH BEHAVIOR: Smart process management
|
||||
// Only kill existing processes if they're for a significantly different seek time
|
||||
const existingProcesses = ffmpegRegistry.getProcessesForVideo(id);
|
||||
let shouldStartNewProcess = true;
|
||||
|
||||
for (const processInfo of existingProcesses) {
|
||||
if (Math.abs(processInfo.seekTime - seekTime) < 2.0) { // Within 2 second tolerance
|
||||
console.log(`[TRANSCODE] Found existing process with similar seek time (${processInfo.seekTime}s vs ${seekTime}s), allowing it to continue`);
|
||||
shouldStartNewProcess = false;
|
||||
// Don't kill the existing process - let it continue serving
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStartNewProcess) {
|
||||
console.log(`[TRANSCODE] Starting fresh FFmpeg process for video ${id} (seek: ${seekTime}s)`);
|
||||
ffmpegRegistry.killAllForVideo(id);
|
||||
|
||||
// Small delay to ensure processes are fully cleaned up
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
} else {
|
||||
console.log(`[TRANSCODE] Allowing existing process to continue serving similar seek time`);
|
||||
// In this case, we still create a new process but could be optimized later
|
||||
// For now, kill and restart to maintain the Stash pattern
|
||||
ffmpegRegistry.killAllForVideo(id);
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
// Create a readable stream from FFmpeg
|
||||
console.log(`[TRANSCODE] Starting FFmpeg process for: ${filePath}`);
|
||||
|
||||
// Build FFmpeg command with seek support (STASH-LIKE: -ss before -i for faster seeking)
|
||||
// Important: Don't use -t parameter to preserve full duration metadata
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
'-v', 'error',
|
||||
...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek BEFORE input (faster)
|
||||
'-i', filePath,
|
||||
'-c:v', 'libx264',
|
||||
'-c:a', 'aac',
|
||||
'-b:v', settings.bitrate,
|
||||
'-s', `${settings.width}x${settings.height}`,
|
||||
const ffmpegCommand = ffmpeg(filePath)
|
||||
.format('mp4')
|
||||
.videoCodec('libx264')
|
||||
.audioCodec('aac')
|
||||
.videoBitrate(settings.bitrate)
|
||||
.size(`${settings.width}x${settings.height}`)
|
||||
.outputOptions([
|
||||
'-preset', 'fast',
|
||||
'-crf', '23',
|
||||
'-movflags', 'frag_keyframe+empty_moov+faststart',
|
||||
|
|
@ -220,62 +101,59 @@ async function createTranscodeStream(
|
|||
'-pix_fmt', 'yuv420p',
|
||||
'-profile:v', 'baseline',
|
||||
'-level', '3.0',
|
||||
// Preserve original metadata to maintain duration info
|
||||
// Ensure proper duration metadata
|
||||
'-map_metadata', '0',
|
||||
'-map_metadata:s:v', '0:s:v',
|
||||
'-map_metadata:s:a', '0:s:a',
|
||||
// Force duration to be preserved
|
||||
'-fflags', '+genpts',
|
||||
'-avoid_negative_ts', 'make_zero',
|
||||
// Add duration override to ensure correct metadata
|
||||
...(duration > 0 ? ['-metadata', `duration=${duration}`] : []),
|
||||
'pipe:1'
|
||||
];
|
||||
|
||||
console.log(`[TRANSCODE] FFmpeg command (Stash-like): ffmpeg ${ffmpegArgs.join(' ')}`);
|
||||
|
||||
// Use direct spawn like Stash (not fluent-ffmpeg)
|
||||
const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
// Register process immediately
|
||||
ffmpegRegistry.register(id, seekTime, ffmpegProcess, ffmpegArgs, quality);
|
||||
console.log(`[TRANSCODE] Registered FFmpeg process for video ${id} with seek ${seekTime}s`);
|
||||
|
||||
// Handle process events
|
||||
ffmpegProcess.on('error', (err) => {
|
||||
// Ensure proper streaming
|
||||
'-frag_duration', '1000000',
|
||||
'-frag_size', '1000000'
|
||||
])
|
||||
.on('start', (commandLine) => {
|
||||
console.log(`[TRANSCODE] FFmpeg started: ${commandLine}`);
|
||||
})
|
||||
.on('error', (err, stdout, stderr) => {
|
||||
console.error(`[TRANSCODE] FFmpeg error:`, err.message);
|
||||
console.log(`[TRANSCODE] FFmpeg errored for ${id}_${seekTime}_${quality}, cleaning up`);
|
||||
console.error(`[TRANSCODE] FFmpeg stdout:`, stdout);
|
||||
console.error(`[TRANSCODE] FFmpeg stderr:`, stderr);
|
||||
})
|
||||
.on('end', () => {
|
||||
console.log(`[TRANSCODE] FFmpeg transcoding completed`);
|
||||
})
|
||||
.on('progress', (progress) => {
|
||||
console.log(`[TRANSCODE] Progress: ${progress.percent}%`);
|
||||
});
|
||||
|
||||
ffmpegProcess.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.log(`[TRANSCODE] FFmpeg process killed with signal: ${signal}`);
|
||||
} else {
|
||||
console.log(`[TRANSCODE] FFmpeg process exited with code: ${code}`);
|
||||
// Create a readable stream
|
||||
const stream = ffmpegCommand.pipe();
|
||||
|
||||
// Track FFmpeg process for cleanup
|
||||
let ffmpegProcess: any = null;
|
||||
let processId = `transcode_${id}_${Date.now()}`;
|
||||
|
||||
ffmpegCommand.on('start', (commandLine) => {
|
||||
// Store process reference for cleanup
|
||||
ffmpegProcess = (ffmpegCommand as any).ffmpegProc;
|
||||
|
||||
// Register process with process manager
|
||||
const cleanup = () => {
|
||||
if (ffmpegProcess) {
|
||||
try {
|
||||
console.log(`[TRANSCODE] Cleaning up FFmpeg process ${processId}`);
|
||||
ffmpegProcess.kill('SIGKILL');
|
||||
} catch (error) {
|
||||
console.error(`[TRANSCODE] Error killing FFmpeg process:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processManager.register(processId, ffmpegProcess, id, cleanup);
|
||||
});
|
||||
|
||||
// Handle stderr for progress and errors
|
||||
ffmpegProcess.stderr?.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
if (output.includes('time=')) {
|
||||
// Parse time for progress calculation
|
||||
const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
|
||||
if (timeMatch && duration > 0) {
|
||||
const [, hours, minutes, seconds, centiseconds] = timeMatch;
|
||||
const currentTime = parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100;
|
||||
const totalDuration = duration - seekTime; // Remaining duration from seek point
|
||||
const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0;
|
||||
console.log(`[TRANSCODE] Progress: ${progress.toFixed(2)}%`);
|
||||
}
|
||||
} else if (output.includes('error') || output.includes('Error')) {
|
||||
console.error(`[TRANSCODE] FFmpeg stderr:`, output.trim());
|
||||
}
|
||||
});
|
||||
|
||||
// Set response headers for streaming with proper duration info
|
||||
// Always use the stored duration, not the seek-adjusted duration
|
||||
// Set response headers for streaming with duration
|
||||
const headers = new Headers({
|
||||
'Content-Type': 'video/mp4',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
|
|
@ -284,31 +162,32 @@ async function createTranscodeStream(
|
|||
'Content-Disposition': 'inline',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'X-Content-Duration': duration.toString(), // Always full duration
|
||||
'X-Seek-Time': seekTime.toString(),
|
||||
'X-Content-Duration': duration.toString(),
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
// Add additional headers for better streaming
|
||||
'Accept-Ranges': 'bytes',
|
||||
'X-Transcoded': 'true',
|
||||
// Add custom header to indicate this is a seeked stream
|
||||
'X-Stream-Start-Time': seekTime.toString(),
|
||||
'X-Stream-Full-Duration': duration.toString(),
|
||||
});
|
||||
|
||||
// Convert Node.js stream to Web Stream for Next.js (use stdout directly)
|
||||
const readableStream = Readable.toWeb(ffmpegProcess.stdout as any) as ReadableStream;
|
||||
// Convert Node.js stream to Web Stream for Next.js
|
||||
const readableStream = Readable.toWeb(stream as any) as ReadableStream;
|
||||
|
||||
console.log(`[TRANSCODE] Sending response with ${settings.width}x${settings.height} video stream, duration: ${duration}s, seek: ${seekTime}s`);
|
||||
console.log(`[TRANSCODE] Sending response with ${settings.width}x${settings.height} video stream, duration: ${duration}s`);
|
||||
|
||||
// Create response with direct stream (no caching like Stash)
|
||||
// Create response
|
||||
const response = new Response(readableStream, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Transcode stream creation error:', error);
|
||||
throw error;
|
||||
console.error('Transcoding API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -320,11 +199,10 @@ export async function DELETE(
|
|||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Use enhanced registry to cleanup all processes for this video ID (Stash-like)
|
||||
const killedCount = ffmpegRegistry.killAllForVideo(id);
|
||||
console.log(`[CLEANUP] Killed ${killedCount} processes for video: ${id}`);
|
||||
// Use process manager to cleanup all processes for this video ID
|
||||
processManager.removeByVideoId(id);
|
||||
|
||||
return NextResponse.json({ success: true, killedProcesses: killedCount });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Cleanup API error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { useState, useEffect, useRef } from 'react';
|
|||
import { X, Play, Pause, Volume2, VolumeX, Maximize, Star, Bookmark } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useProtectedDuration } from '@/lib/hooks/use-protected-duration';
|
||||
import { useStableProgress, formatTime } from '@/lib/hooks/use-stable-progress';
|
||||
|
||||
interface Video {
|
||||
id: number;
|
||||
|
|
@ -55,38 +53,13 @@ export default function VideoViewer({
|
|||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||
const [bookmarkCount, setBookmarkCount] = useState(0);
|
||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||
const [transcodingError, setTranscodingError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const videoRef = useRef<HTMLVideoElement>(null!);
|
||||
const lastTranscodingUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Use protected duration hook for accurate duration display
|
||||
const {
|
||||
duration,
|
||||
isLoading: isDurationLoading,
|
||||
error: durationError,
|
||||
handleDurationChange: protectedHandleDurationChange,
|
||||
refreshDuration
|
||||
} = useProtectedDuration({
|
||||
videoId: video && 'id' in video && video.id !== undefined ? video.id.toString() : ''
|
||||
});
|
||||
|
||||
// Use stable progress hook for anti-jitter
|
||||
const {
|
||||
currentTime,
|
||||
bufferState,
|
||||
isDragging,
|
||||
handleTimeUpdate: stableHandleTimeUpdate,
|
||||
handleProgress: stableHandleProgress,
|
||||
handleSeek: stableHandleSeek,
|
||||
handleSeekStart: stableHandleSeekStart,
|
||||
handleSeekEnd: stableHandleSeekEnd,
|
||||
resetProgress
|
||||
} = useStableProgress(videoRef, duration);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Heartbeat mechanism
|
||||
const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||
|
|
@ -166,108 +139,22 @@ export default function VideoViewer({
|
|||
const videoId = getVideoId();
|
||||
if (!videoId) return;
|
||||
|
||||
// Reset hooks for new video
|
||||
resetProgress();
|
||||
// Let the useProtectedDuration hook handle duration fetching internally
|
||||
|
||||
// First check if this video needs transcoding
|
||||
const checkTranscodingNeeded = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/videos/${videoId}`);
|
||||
const videoData = await response.json();
|
||||
|
||||
let codecInfo = { needsTranscoding: false };
|
||||
try {
|
||||
codecInfo = JSON.parse(videoData.codec_info || '{}');
|
||||
} catch {
|
||||
// Fallback if codec info is invalid
|
||||
}
|
||||
|
||||
if (codecInfo.needsTranscoding) {
|
||||
console.log(`[PLAYER] Video ${videoId} needs transcoding, using transcoding endpoint directly`);
|
||||
setIsTranscoding(true);
|
||||
setTranscodingError(null);
|
||||
const transcodingUrl = `/api/stream/${videoId}/transcode`;
|
||||
lastTranscodingUrlRef.current = transcodingUrl;
|
||||
videoRef.current!.src = transcodingUrl;
|
||||
videoRef.current!.load();
|
||||
} else {
|
||||
console.log(`[PLAYER] Video ${videoId} can be streamed directly`);
|
||||
setIsTranscoding(false);
|
||||
videoRef.current!.src = `/api/stream/${videoId}`;
|
||||
videoRef.current!.load();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PLAYER] Error checking transcoding needs:`, error);
|
||||
// Fallback to direct stream
|
||||
videoRef.current!.src = `/api/stream/${videoId}`;
|
||||
videoRef.current!.load();
|
||||
}
|
||||
};
|
||||
|
||||
checkTranscodingNeeded();
|
||||
|
||||
// Handle video load errors (simplified since we pre-check transcoding needs)
|
||||
const handleError = async () => {
|
||||
const currentSrc = videoRef.current?.src;
|
||||
const isAlreadyTranscoding = currentSrc?.includes('/transcode');
|
||||
|
||||
console.log(`[PLAYER] Video error, src: ${currentSrc}, transcoding: ${isAlreadyTranscoding}, retries: ${retryCount}`);
|
||||
|
||||
if (!isAlreadyTranscoding && retryCount < 2) {
|
||||
console.log('Direct stream failed, trying transcoded version...');
|
||||
setIsTranscoding(true);
|
||||
setTranscodingError(null);
|
||||
setRetryCount(prev => prev + 1);
|
||||
|
||||
// Clean up any existing transcoding streams first
|
||||
try {
|
||||
await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' });
|
||||
} catch (cleanupError) {
|
||||
console.log('Cleanup warning (non-critical):', cleanupError);
|
||||
}
|
||||
|
||||
// Wait a moment before starting new transcode
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
const transcodingUrl = `/api/stream/${videoId}/transcode?retry=${retryCount}`;
|
||||
lastTranscodingUrlRef.current = transcodingUrl;
|
||||
videoRef.current.src = transcodingUrl;
|
||||
videoRef.current.src = `/api/stream/${videoId}`;
|
||||
videoRef.current.load();
|
||||
}
|
||||
}, 1000);
|
||||
} else if (isAlreadyTranscoding && retryCount < 3) {
|
||||
console.log('Transcoding error, retrying...');
|
||||
setRetryCount(prev => prev + 1);
|
||||
|
||||
// Clean up and retry transcoding
|
||||
try {
|
||||
await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' });
|
||||
} catch (cleanupError) {
|
||||
console.log('Cleanup warning (non-critical):', cleanupError);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// Handle video load errors (fallback to transcoding)
|
||||
const handleError = () => {
|
||||
console.log('Video load failed, trying transcoded version...');
|
||||
if (videoRef.current) {
|
||||
const transcodingUrl = `/api/stream/${videoId}/transcode?retry=${retryCount}`;
|
||||
lastTranscodingUrlRef.current = transcodingUrl;
|
||||
videoRef.current.src = transcodingUrl;
|
||||
setIsTranscoding(true);
|
||||
videoRef.current.src = `/api/stream/${videoId}/transcode`;
|
||||
videoRef.current.load();
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
console.error('Maximum retry attempts reached');
|
||||
setTranscodingError('Failed to load video after multiple attempts. The video may be corrupted or in an unsupported format.');
|
||||
setIsTranscoding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-play when video is loaded
|
||||
const handleLoadedData = () => {
|
||||
if (videoRef.current) {
|
||||
setTranscodingError(null); // Clear any previous errors
|
||||
setRetryCount(0); // Reset retry count on successful load
|
||||
|
||||
videoRef.current.play().then(() => {
|
||||
setIsPlaying(true);
|
||||
}).catch((error) => {
|
||||
|
|
@ -276,24 +163,41 @@ export default function VideoViewer({
|
|||
}
|
||||
};
|
||||
|
||||
// Handle metadata loaded to get duration (with protection)
|
||||
// Handle metadata loaded to get duration
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
const videoDuration = videoRef.current.duration;
|
||||
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
|
||||
console.log(`[PLAYER] Metadata duration: ${videoDuration}s`);
|
||||
protectedHandleDurationChange(videoDuration);
|
||||
console.log(`[PLAYER] Duration from metadata: ${videoDuration}s`);
|
||||
setDuration(videoDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle duration change events (with protection)
|
||||
// Handle response headers to get duration for transcoded streams
|
||||
const handleResponseHeaders = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/stream/${videoId}${isTranscoding ? '/transcode' : ''}`);
|
||||
const contentDuration = response.headers.get('X-Content-Duration');
|
||||
if (contentDuration) {
|
||||
const durationValue = parseFloat(contentDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
console.log(`[PLAYER] Duration from headers: ${durationValue}s (transcoded: ${isTranscoding})`);
|
||||
setDuration(durationValue);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not fetch duration from headers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle duration change events
|
||||
const handleDurationChange = () => {
|
||||
if (videoRef.current) {
|
||||
const videoDuration = videoRef.current.duration;
|
||||
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
|
||||
console.log(`[PLAYER] Duration change: ${videoDuration}s`);
|
||||
protectedHandleDurationChange(videoDuration);
|
||||
console.log(`[PLAYER] Duration changed: ${videoDuration}s`);
|
||||
setDuration(videoDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -303,6 +207,9 @@ export default function VideoViewer({
|
|||
videoRef.current.addEventListener('durationchange', handleDurationChange);
|
||||
videoRef.current.addEventListener('error', handleError);
|
||||
|
||||
// Try to get duration from headers
|
||||
handleResponseHeaders();
|
||||
|
||||
return () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.removeEventListener('loadeddata', handleLoadedData);
|
||||
|
|
@ -317,30 +224,31 @@ export default function VideoViewer({
|
|||
}
|
||||
}, [isOpen, video, isTranscoding]);
|
||||
|
||||
// Separate effect for hook event listeners to avoid infinite re-renders
|
||||
// Fetch duration when transcoding state changes
|
||||
useEffect(() => {
|
||||
if (!isOpen || !videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
// Add event listeners for the hooks
|
||||
video.addEventListener('timeupdate', stableHandleTimeUpdate);
|
||||
video.addEventListener('progress', stableHandleProgress);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', stableHandleTimeUpdate);
|
||||
video.removeEventListener('progress', stableHandleProgress);
|
||||
};
|
||||
}, [isOpen]); // Only depend on isOpen, not the functions
|
||||
|
||||
// Reset hooks when video changes
|
||||
useEffect(() => {
|
||||
if (isOpen && video) {
|
||||
resetProgress();
|
||||
// Don't call refreshDuration here - let the hook handle it internally
|
||||
if (isTranscoding) {
|
||||
const videoId = getVideoId();
|
||||
if (videoId) {
|
||||
const fetchTranscodedDuration = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/stream/${videoId}/transcode`);
|
||||
const contentDuration = response.headers.get('X-Content-Duration');
|
||||
if (contentDuration) {
|
||||
const durationValue = parseFloat(contentDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
console.log(`[PLAYER] Transcoding duration: ${durationValue}s`);
|
||||
setDuration(durationValue);
|
||||
}
|
||||
}, [isOpen, video]); // Remove function dependencies
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not fetch transcoded duration:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTranscodedDuration();
|
||||
}
|
||||
}
|
||||
}, [isTranscoding, video]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
|
|
@ -415,41 +323,26 @@ export default function VideoViewer({
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const handleSeek = async (newTime: number) => {
|
||||
const videoId = getVideoId();
|
||||
if (!videoId || !videoRef.current) return;
|
||||
|
||||
// For transcoded videos, use seek-optimized transcoding
|
||||
if (isTranscoding) {
|
||||
console.log(`[PLAYER] Seek-optimized transcoding to ${newTime}s`);
|
||||
|
||||
// Prevent multiple simultaneous requests
|
||||
const newTranscodingUrl = `/api/stream/${videoId}/transcode?seek=${newTime}&t=${Date.now()}`;
|
||||
if (lastTranscodingUrlRef.current === newTranscodingUrl) {
|
||||
console.log(`[PLAYER] Skipping duplicate transcoding request`);
|
||||
return;
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Kill current transcoding process
|
||||
await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' });
|
||||
|
||||
// Wait a moment to ensure cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Start new transcoding with seek parameter
|
||||
lastTranscodingUrlRef.current = newTranscodingUrl;
|
||||
videoRef.current.src = newTranscodingUrl;
|
||||
videoRef.current.load();
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup transcoding process:', error);
|
||||
// Try fallback direct seek
|
||||
stableHandleSeek(newTime);
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
const videoDuration = videoRef.current.duration;
|
||||
if (videoDuration && videoDuration > 0) {
|
||||
setDuration(videoDuration);
|
||||
}
|
||||
} else {
|
||||
// Direct video seeking
|
||||
stableHandleSeek(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = parseFloat(e.target.value);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -522,6 +415,11 @@ export default function VideoViewer({
|
|||
return 0;
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!isOpen || typeof window === 'undefined') return null;
|
||||
|
||||
|
|
@ -537,39 +435,13 @@ export default function VideoViewer({
|
|||
</button>
|
||||
|
||||
{/* Transcoding indicator */}
|
||||
{isTranscoding && !transcodingError && (
|
||||
{isTranscoding && (
|
||||
<div className="absolute top-4 left-4 z-10 bg-yellow-500/20 text-yellow-600 rounded-full px-3 py-1.5 flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm">Transcoding</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error indicator */}
|
||||
{transcodingError && (
|
||||
<div className="absolute top-4 left-4 right-4 z-10 bg-red-500/20 border border-red-500/50 text-red-400 rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full flex-shrink-0"></div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">Playback Error</div>
|
||||
<div className="text-xs opacity-90">{transcodingError}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const videoId = getVideoId();
|
||||
if (videoId && videoRef.current) {
|
||||
setTranscodingError(null);
|
||||
setRetryCount(0);
|
||||
setIsTranscoding(false);
|
||||
videoRef.current.src = `/api/stream/${videoId}`;
|
||||
videoRef.current.load();
|
||||
}
|
||||
}}
|
||||
className="text-red-400 hover:text-red-300 text-xs underline flex-shrink-0"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video container */}
|
||||
<div
|
||||
className="relative w-full h-full bg-black rounded-lg overflow-hidden"
|
||||
|
|
@ -579,6 +451,8 @@ export default function VideoViewer({
|
|||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onMouseMove={() => setShowControls(true)}
|
||||
|
|
@ -594,54 +468,20 @@ export default function VideoViewer({
|
|||
|
||||
{/* Controls overlay */}
|
||||
<div className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Enhanced Progress bar with buffer visualization */}
|
||||
{/* Progress bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative w-full h-2 bg-gray-600 rounded-lg overflow-hidden">
|
||||
{/* Buffer indicator */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-400/30 rounded-lg"
|
||||
style={{
|
||||
width: `${Math.min((bufferState.buffered / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute top-0 h-full bg-blue-500 rounded-lg transition-all duration-100"
|
||||
style={{
|
||||
width: `${Math.min((currentTime / (duration || 1)) * 100, 100)}%`,
|
||||
left: '0'
|
||||
}}
|
||||
/>
|
||||
{/* Seek input overlay */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={(e) => handleSeek(parseFloat(e.target.value))}
|
||||
onMouseDown={stableHandleSeekStart}
|
||||
onMouseUp={stableHandleSeekEnd}
|
||||
disabled={isDurationLoading}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
onChange={handleSeek}
|
||||
className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-white text-sm mt-1">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
{isDurationLoading ? (
|
||||
<span className="text-gray-400">Loading...</span>
|
||||
) : (
|
||||
<span>{formatTime(duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Buffer status */}
|
||||
{bufferState.buffered > 0 && (
|
||||
<div className="text-xs text-blue-300 mt-1">
|
||||
Buffered: {formatTime(bufferState.buffered)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video Info Bar (similar to photo viewer) */}
|
||||
|
|
@ -651,9 +491,7 @@ export default function VideoViewer({
|
|||
<h3 className="text-white font-medium">{getVideoTitle()}</h3>
|
||||
<p className="text-gray-300 text-sm">{getVideoSize()}</p>
|
||||
{duration > 0 && (
|
||||
<p className="text-gray-300 text-sm">Duration: {formatTime(duration)}
|
||||
{isTranscoding && <span className="text-yellow-400 ml-1">(Transcoded)</span>}
|
||||
</p>
|
||||
<p className="text-gray-300 text-sm">Duration: {formatTime(duration)}</p>
|
||||
)}
|
||||
</div>
|
||||
{(showBookmarks || showRatings) && (
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
import { ChildProcess } from 'child_process';
|
||||
|
||||
interface FFmpegProcessInfo {
|
||||
process: ChildProcess;
|
||||
startTime: Date;
|
||||
seekTime: number;
|
||||
videoId: string;
|
||||
command: string[];
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced FFmpeg Process Registry for seek-optimized transcoding
|
||||
* Inspired by Stash's approach: kill old processes, start new ones with -ss parameter
|
||||
*/
|
||||
export class FFmpegProcessRegistry {
|
||||
private processes = new Map<string, FFmpegProcessInfo>();
|
||||
|
||||
/**
|
||||
* Register a new FFmpeg process
|
||||
* @param videoId The video ID
|
||||
* @param seekTime The seek time in seconds
|
||||
* @param process The FFmpeg child process
|
||||
* @param command The FFmpeg command arguments
|
||||
* @param quality Optional quality setting
|
||||
*/
|
||||
register(
|
||||
videoId: string,
|
||||
seekTime: number,
|
||||
process: ChildProcess,
|
||||
command: string[],
|
||||
quality?: string
|
||||
): string {
|
||||
const key = `${videoId}_${seekTime}_${quality || 'default'}`;
|
||||
|
||||
// Kill existing process for this video if different seek time
|
||||
this.killExisting(videoId, seekTime, quality);
|
||||
|
||||
this.processes.set(key, {
|
||||
process,
|
||||
startTime: new Date(),
|
||||
seekTime,
|
||||
videoId,
|
||||
command,
|
||||
quality
|
||||
});
|
||||
|
||||
console.log(`[FFMPEG_REGISTRY] Registered process: ${key} (seek: ${seekTime}s)`);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill existing processes for a video that have different seek times
|
||||
*/
|
||||
private killExisting(videoId: string, newSeekTime: number, quality?: string): void {
|
||||
const processesToKill: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
const [entryVideoId, entrySeekTime, entryQuality] = key.split('_');
|
||||
|
||||
if (entryVideoId === videoId &&
|
||||
(entrySeekTime !== newSeekTime.toString() ||
|
||||
(entryQuality && entryQuality !== (quality || 'default')))) {
|
||||
processesToKill.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
processesToKill.forEach(key => this.killProcess(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a specific process
|
||||
*/
|
||||
killProcess(key: string): boolean {
|
||||
const entry = this.processes.get(key);
|
||||
if (entry && !entry.process.killed) {
|
||||
try {
|
||||
console.log(`[FFMPEG_REGISTRY] Killing process: ${key}`);
|
||||
entry.process.kill('SIGKILL');
|
||||
this.processes.delete(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[FFMPEG_REGISTRY] Error killing process ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all processes for a specific video
|
||||
*/
|
||||
killAllForVideo(videoId: string): number {
|
||||
const processesToKill: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
if (entry.videoId === videoId) {
|
||||
processesToKill.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
let killedCount = 0;
|
||||
processesToKill.forEach(key => {
|
||||
if (this.killProcess(key)) {
|
||||
killedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (killedCount > 0) {
|
||||
console.log(`[FFMPEG_REGISTRY] Killed ${killedCount} processes for video: ${videoId}`);
|
||||
}
|
||||
|
||||
return killedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active processes for a video
|
||||
*/
|
||||
getProcessesForVideo(videoId: string): Array<{
|
||||
key: string;
|
||||
seekTime: number;
|
||||
uptime: number;
|
||||
command: string[];
|
||||
quality?: 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,
|
||||
quality: entry.quality
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active processes
|
||||
*/
|
||||
getAllProcesses(): Array<{
|
||||
key: string;
|
||||
videoId: string;
|
||||
seekTime: number;
|
||||
uptime: number;
|
||||
command: string[];
|
||||
quality?: string;
|
||||
}> {
|
||||
return Array.from(this.processes.entries()).map(([key, entry]) => ({
|
||||
key,
|
||||
videoId: entry.videoId,
|
||||
seekTime: entry.seekTime,
|
||||
uptime: Date.now() - entry.startTime.getTime(),
|
||||
command: entry.command,
|
||||
quality: entry.quality
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get process count for a video
|
||||
*/
|
||||
getProcessCountForVideo(videoId: string): number {
|
||||
return Array.from(this.processes.values())
|
||||
.filter(entry => entry.videoId === videoId)
|
||||
.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total process count
|
||||
*/
|
||||
getTotalProcessCount(): number {
|
||||
return this.processes.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale processes (older than maxAge milliseconds)
|
||||
*/
|
||||
cleanupStaleProcesses(maxAge: number = 10 * 60 * 1000): number {
|
||||
const now = Date.now();
|
||||
const staleProcesses: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
if (now - entry.startTime.getTime() > maxAge) {
|
||||
staleProcesses.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
let cleanedCount = 0;
|
||||
staleProcesses.forEach(key => {
|
||||
if (this.killProcess(key)) {
|
||||
cleanedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
console.log(`[FFMPEG_REGISTRY] Cleaned up ${cleanedCount} stale processes`);
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all processes
|
||||
*/
|
||||
cleanupAll(): number {
|
||||
const processKeys = Array.from(this.processes.keys());
|
||||
let cleanedCount = 0;
|
||||
|
||||
processKeys.forEach(key => {
|
||||
if (this.killProcess(key)) {
|
||||
cleanedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[FFMPEG_REGISTRY] Cleaned up all ${cleanedCount} processes`);
|
||||
return cleanedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const ffmpegRegistry = new FFmpegProcessRegistry();
|
||||
|
||||
// Cleanup on process exit
|
||||
if (typeof process !== 'undefined') {
|
||||
process.on('exit', () => {
|
||||
ffmpegRegistry.cleanupAll();
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
ffmpegRegistry.cleanupAll();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
ffmpegRegistry.cleanupAll();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface UseProtectedDurationOptions {
|
||||
videoId?: string;
|
||||
fallbackDuration?: number;
|
||||
}
|
||||
|
||||
interface UseProtectedDurationReturn {
|
||||
duration: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
handleDurationChange: (newDuration: number) => void;
|
||||
refreshDuration: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration protection hook that ensures real video duration is displayed
|
||||
* instead of buffered duration. Uses existing codec_info from database.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Database-stored duration (codec_info.duration) - most reliable
|
||||
* 2. HTTP headers from transcoding endpoint
|
||||
* 3. Video element metadata (fallback, can be buffered duration)
|
||||
*/
|
||||
export function useProtectedDuration({
|
||||
videoId,
|
||||
fallbackDuration = 0
|
||||
}: UseProtectedDurationOptions): UseProtectedDurationReturn {
|
||||
const [duration, setDuration] = useState(fallbackDuration);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasRealDuration = useRef(false);
|
||||
const lastFetchedVideoId = useRef<string | null>(null);
|
||||
|
||||
/**
|
||||
* Handle duration changes from video element
|
||||
* Only accept significant changes if we don't have real duration
|
||||
*/
|
||||
const handleDurationChange = useCallback((newDuration: number): void => {
|
||||
// If we have real duration from database/headers, ignore metadata changes
|
||||
if (hasRealDuration.current) {
|
||||
console.log(`[DURATION] Blocked metadata duration: ${newDuration}s (using stored: ${duration}s)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only accept significantly larger durations (not buffered durations)
|
||||
if (newDuration > duration * 2.0 && newDuration > 0) {
|
||||
console.log(`[DURATION] Accepted metadata duration: ${newDuration}s`);
|
||||
setDuration(newDuration);
|
||||
hasRealDuration.current = true; // Mark as having real duration
|
||||
} else if (newDuration <= 0) {
|
||||
console.log(`[DURATION] Ignored invalid duration: ${newDuration}s`);
|
||||
} else {
|
||||
console.log(`[DURATION] Ignored buffered duration: ${newDuration}s (current: ${duration}s)`);
|
||||
}
|
||||
}, [duration]); // Only depend on duration
|
||||
|
||||
/**
|
||||
* Refresh duration from database
|
||||
*/
|
||||
const refreshDuration = useCallback(async (): Promise<void> => {
|
||||
hasRealDuration.current = false; // Reset to allow re-fetch
|
||||
lastFetchedVideoId.current = null; // Force re-fetch by clearing the cache
|
||||
|
||||
// Re-run the effect by updating a dummy state
|
||||
setError(null);
|
||||
}, []); // No dependencies to prevent re-creation
|
||||
|
||||
// Fetch duration when videoId changes
|
||||
useEffect(() => {
|
||||
if (videoId && videoId !== lastFetchedVideoId.current) {
|
||||
hasRealDuration.current = false; // Reset for new video
|
||||
|
||||
const fetchDuration = async () => {
|
||||
if (!videoId) {
|
||||
setDuration(fallbackDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Try database first (codec_info has real duration)
|
||||
const response = await fetch(`/api/videos/${videoId}`);
|
||||
if (response.ok) {
|
||||
const videoData = await response.json();
|
||||
console.log(`[DURATION] Video data:`, videoData);
|
||||
if (videoData.codec_info) {
|
||||
const codecInfo = JSON.parse(videoData.codec_info);
|
||||
console.log(`[DURATION] Codec info:`, codecInfo);
|
||||
if (codecInfo.duration && codecInfo.duration > 0) {
|
||||
setDuration(codecInfo.duration);
|
||||
hasRealDuration.current = true;
|
||||
console.log(`[DURATION] Using database duration: ${codecInfo.duration}s`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[DURATION] API response not ok:`, response.status, response.statusText);
|
||||
}
|
||||
|
||||
// 2. Try transcoding headers if transcoding
|
||||
const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`);
|
||||
const headerDuration = transcodingResponse.headers.get('X-Content-Duration');
|
||||
if (headerDuration) {
|
||||
const durationValue = parseFloat(headerDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
setDuration(durationValue);
|
||||
hasRealDuration.current = true;
|
||||
console.log(`[DURATION] Using header duration: ${durationValue}s`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Use fallback
|
||||
console.log(`[DURATION] Using fallback duration: ${fallbackDuration}s`);
|
||||
setDuration(fallbackDuration);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DURATION] Error fetching duration:', error);
|
||||
setError(error instanceof Error ? error.message : 'Unknown error');
|
||||
setDuration(fallbackDuration);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDuration();
|
||||
lastFetchedVideoId.current = videoId;
|
||||
}
|
||||
}, [videoId, fallbackDuration]); // Simple dependencies
|
||||
|
||||
return {
|
||||
duration,
|
||||
isLoading,
|
||||
error,
|
||||
handleDurationChange,
|
||||
refreshDuration
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get duration directly from API
|
||||
*/
|
||||
export async function getRealDuration(videoId: string): Promise<number> {
|
||||
try {
|
||||
// 1. Try database first
|
||||
const response = await fetch(`/api/videos/${videoId}`);
|
||||
if (response.ok) {
|
||||
const videoData = await response.json();
|
||||
if (videoData.codec_info) {
|
||||
const codecInfo = JSON.parse(videoData.codec_info);
|
||||
if (codecInfo.duration && codecInfo.duration > 0) {
|
||||
return codecInfo.duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try transcoding headers
|
||||
const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`);
|
||||
const headerDuration = transcodingResponse.headers.get('X-Content-Duration');
|
||||
if (headerDuration) {
|
||||
const durationValue = parseFloat(headerDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
return durationValue;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error('[DURATION] Error getting real duration:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
import { useState, useRef, useEffect, useCallback, RefObject } from 'react';
|
||||
|
||||
interface BufferState {
|
||||
buffered: number;
|
||||
lastBufferUpdate: number;
|
||||
}
|
||||
|
||||
interface UseStableProgressReturn {
|
||||
currentTime: number;
|
||||
bufferState: BufferState;
|
||||
isDragging: boolean;
|
||||
handleTimeUpdate: () => void;
|
||||
handleProgress: () => void;
|
||||
handleSeek: (time: number) => void;
|
||||
handleSeekStart: () => void;
|
||||
handleSeekEnd: () => void;
|
||||
resetProgress: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable progress hook that prevents backward jumps during buffering
|
||||
* and provides smooth progress updates like Stash
|
||||
*/
|
||||
export function useStableProgress(
|
||||
videoRef: RefObject<HTMLVideoElement>,
|
||||
duration: number,
|
||||
options: {
|
||||
jitterThreshold?: number;
|
||||
updateThrottle?: number;
|
||||
smallAdjustmentThreshold?: number;
|
||||
} = {}
|
||||
): UseStableProgressReturn {
|
||||
const {
|
||||
jitterThreshold = 0.5, // Maximum allowed backward jump in seconds
|
||||
updateThrottle = 100, // Minimum ms between updates
|
||||
smallAdjustmentThreshold = 0.1 // Allow small backward adjustments
|
||||
} = options;
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [bufferState, setBufferState] = useState<BufferState>({
|
||||
buffered: 0,
|
||||
lastBufferUpdate: 0
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Refs for internal state management
|
||||
const lastStableTime = useRef(0);
|
||||
const lastUpdateTime = useRef(0);
|
||||
const bufferStateRef = useRef(bufferState);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
// Refs for configuration values to prevent unnecessary re-renders
|
||||
const configRef = useRef({
|
||||
updateThrottle,
|
||||
smallAdjustmentThreshold,
|
||||
jitterThreshold
|
||||
});
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
bufferStateRef.current = bufferState;
|
||||
}, [bufferState]);
|
||||
|
||||
useEffect(() => {
|
||||
isDraggingRef.current = isDragging;
|
||||
}, [isDragging]);
|
||||
|
||||
// Update config refs when options change
|
||||
useEffect(() => {
|
||||
configRef.current = {
|
||||
updateThrottle,
|
||||
smallAdjustmentThreshold,
|
||||
jitterThreshold
|
||||
};
|
||||
}, [updateThrottle, smallAdjustmentThreshold, jitterThreshold]);
|
||||
|
||||
|
||||
/**
|
||||
* Internal time update handler that manages the state directly
|
||||
*/
|
||||
const internalTimeUpdate = useCallback((): void => {
|
||||
if (!videoRef.current || isDraggingRef.current) return;
|
||||
|
||||
const now = Date.now();
|
||||
const video = videoRef.current;
|
||||
const rawTime = video.currentTime;
|
||||
|
||||
// Check for seek offset in video source URL
|
||||
const seekOffset = getSeekOffsetFromVideoSrc(video.src);
|
||||
const actualTime = rawTime + seekOffset; // Adjust for seek offset
|
||||
|
||||
// Throttle updates to prevent excessive re-renders
|
||||
if (now - lastUpdateTime.current < configRef.current.updateThrottle) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow forward progress or small backward adjustments
|
||||
if (actualTime >= lastStableTime.current - configRef.current.smallAdjustmentThreshold) {
|
||||
setCurrentTime(actualTime);
|
||||
lastStableTime.current = actualTime;
|
||||
lastUpdateTime.current = now;
|
||||
} else {
|
||||
// Block large backward jumps (jitter prevention)
|
||||
console.log(`[ANTI-JITTER] Blocked backward jump: ${actualTime.toFixed(2)}s -> ${lastStableTime.current.toFixed(2)}s (threshold: ${configRef.current.jitterThreshold}s, seek offset: ${seekOffset}s)`);
|
||||
|
||||
// Optionally clamp to last stable time to prevent visual jumping
|
||||
if (Math.abs(actualTime - lastStableTime.current) > configRef.current.jitterThreshold) {
|
||||
// Don't modify video.currentTime when there's a seek offset - it would break the stream
|
||||
if (seekOffset === 0) {
|
||||
video.currentTime = lastStableTime.current;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []); // Remove videoRef dependency to prevent re-creation
|
||||
|
||||
/**
|
||||
* Handle progress events for buffer visualization
|
||||
*/
|
||||
const handleProgress = useCallback((): void => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
const newBufferState = {
|
||||
buffered: bufferedEnd,
|
||||
lastBufferUpdate: Date.now()
|
||||
};
|
||||
|
||||
setBufferState(newBufferState);
|
||||
|
||||
// Log for debugging
|
||||
if (Math.abs(bufferedEnd - bufferStateRef.current.buffered) > 1) {
|
||||
console.log(`[BUFFER] Updated buffer to ${bufferedEnd.toFixed(2)}s`);
|
||||
}
|
||||
}
|
||||
}, []); // Remove videoRef dependency to prevent re-creation
|
||||
|
||||
/**
|
||||
* Handle seeking with proper state management
|
||||
*/
|
||||
const handleSeek = useCallback((time: number): void => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const clampedTime = Math.max(0, Math.min(time, duration || Infinity));
|
||||
|
||||
// Update both refs and state immediately for responsive UI
|
||||
lastStableTime.current = clampedTime;
|
||||
setCurrentTime(clampedTime);
|
||||
|
||||
// Update video element
|
||||
videoRef.current.currentTime = clampedTime;
|
||||
|
||||
console.log(`[SEEK] Seeking to ${clampedTime.toFixed(2)}s`);
|
||||
}, [duration]); // Only depend on duration
|
||||
|
||||
/**
|
||||
* Handle seek start (mouse down on progress bar)
|
||||
*/
|
||||
const handleSeekStart = useCallback((): void => {
|
||||
setIsDragging(true);
|
||||
isDraggingRef.current = true;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle seek end (mouse up on progress bar)
|
||||
*/
|
||||
const handleSeekEnd = useCallback((): void => {
|
||||
setIsDragging(false);
|
||||
isDraggingRef.current = false;
|
||||
|
||||
// Sync with video element after seek
|
||||
if (videoRef.current) {
|
||||
const finalTime = videoRef.current.currentTime;
|
||||
lastStableTime.current = finalTime;
|
||||
setCurrentTime(finalTime);
|
||||
}
|
||||
}, []); // Remove videoRef dependency
|
||||
|
||||
/**
|
||||
* Reset progress state (for video changes)
|
||||
*/
|
||||
const resetProgress = useCallback((): void => {
|
||||
// Get seek offset if video is loaded
|
||||
const seekOffset = videoRef.current ? getSeekOffsetFromVideoSrc(videoRef.current.src) : 0;
|
||||
|
||||
// Use functional updates to avoid dependencies on current state
|
||||
setCurrentTime(() => seekOffset); // Start from seek offset, not 0
|
||||
setBufferState(() => ({ buffered: 0, lastBufferUpdate: 0 }));
|
||||
lastStableTime.current = seekOffset; // Initialize to seek offset
|
||||
lastUpdateTime.current = 0;
|
||||
isDraggingRef.current = false;
|
||||
setIsDragging(() => false);
|
||||
|
||||
if (seekOffset > 0) {
|
||||
console.log(`[PROGRESS] Reset progress with seek offset: ${seekOffset}s`);
|
||||
}
|
||||
}, []); // Empty deps array is safe with functional updates
|
||||
|
||||
// Reset when video source changes
|
||||
useEffect(() => {
|
||||
resetProgress();
|
||||
}, []); // Only reset on mount/unmount
|
||||
|
||||
return {
|
||||
currentTime,
|
||||
bufferState,
|
||||
isDragging,
|
||||
handleTimeUpdate: internalTimeUpdate,
|
||||
handleProgress,
|
||||
handleSeek,
|
||||
handleSeekStart,
|
||||
handleSeekEnd,
|
||||
resetProgress
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to extract seek offset from video source URL
|
||||
*/
|
||||
function getSeekOffsetFromVideoSrc(src: string): number {
|
||||
try {
|
||||
if (!src || !src.includes('seek=')) return 0;
|
||||
|
||||
const url = new URL(src, window.location.origin);
|
||||
const seekParam = url.searchParams.get('seek');
|
||||
return seekParam ? parseFloat(seekParam) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to format time for display
|
||||
*/
|
||||
export function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds < 0) return '0:00';
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for monitoring buffer state separately
|
||||
*/
|
||||
export function useBufferState(videoRef: RefObject<HTMLVideoElement>) {
|
||||
const [bufferState, setBufferState] = useState<BufferState>({
|
||||
buffered: 0,
|
||||
lastBufferUpdate: 0
|
||||
});
|
||||
|
||||
const handleProgress = (): void => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
setBufferState({
|
||||
buffered: bufferedEnd,
|
||||
lastBufferUpdate: Date.now()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.addEventListener('progress', handleProgress);
|
||||
return () => video.removeEventListener('progress', handleProgress);
|
||||
}, [videoRef]);
|
||||
|
||||
return bufferState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing seek state
|
||||
*/
|
||||
export function useSeekState() {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleSeekStart = (): void => setIsDragging(true);
|
||||
const handleSeekEnd = (): void => setIsDragging(false);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
handleSeekStart,
|
||||
handleSeekEnd
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration presets for different scenarios
|
||||
*/
|
||||
export const progressConfig = {
|
||||
strict: {
|
||||
jitterThreshold: 0.2,
|
||||
updateThrottle: 50,
|
||||
smallAdjustmentThreshold: 0.05
|
||||
},
|
||||
balanced: {
|
||||
jitterThreshold: 0.5,
|
||||
updateThrottle: 100,
|
||||
smallAdjustmentThreshold: 0.1
|
||||
},
|
||||
relaxed: {
|
||||
jitterThreshold: 1.0,
|
||||
updateThrottle: 200,
|
||||
smallAdjustmentThreshold: 0.2
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue