feat(api): add FFmpeg process status and management API endpoints

- Implement GET /api/ffmpeg/status to return current FFmpeg process status
- Support filtering by videoId and response formatting (JSON or text table)
- Include optional statistics in status response
- Implement DELETE /api/ffmpeg/status for process cleanup operations
- Allow killing processes by videoId, stale status, or all at once
- Add cache-control and CORS headers for API responses
- Add error handling with 500 response on failure
This commit is contained in:
tigeren 2025-09-06 16:39:00 +00:00
parent b93bd26825
commit 13d6874c00
11 changed files with 2113 additions and 111 deletions

Binary file not shown.

View File

@ -0,0 +1,358 @@
# 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.

View File

@ -0,0 +1,456 @@
# 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

View File

@ -0,0 +1,104 @@
# 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.

60
docs/stash.md Normal file
View File

@ -0,0 +1,60 @@
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 #

View File

@ -0,0 +1,259 @@
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`;
}
}

View File

@ -3,7 +3,7 @@ import { getDatabase } from '@/db';
import fs from 'fs';
import ffmpeg from 'fluent-ffmpeg';
import { Readable } from 'stream';
import { processManager } from '@/lib/process-manager';
import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry';
export async function OPTIONS(
request: NextRequest,
@ -68,9 +68,15 @@ export async function GET(
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
// Get quality parameter
// Get parameters
const searchParams = request.nextUrl.searchParams;
const quality = searchParams.get('quality') || '720p';
const seek = searchParams.get('seek') || '0';
const seekTime = parseFloat(seek);
// Kill existing processes for this video (Stash-like behavior)
console.log(`[TRANSCODE] Killing existing processes for video ${id}`);
ffmpegRegistry.killAllForVideo(id);
// Configure transcoding based on quality
const qualitySettings = {
@ -84,6 +90,35 @@ export async function GET(
// Create a readable stream from FFmpeg
console.log(`[TRANSCODE] Starting FFmpeg process for: ${filePath}`);
// Build FFmpeg command with seek support
const ffmpegArgs = [
'-hide_banner',
'-v', 'error',
...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek support like Stash
'-i', filePath,
'-c:v', 'libx264',
'-c:a', 'aac',
'-b:v', settings.bitrate,
'-s', `${settings.width}x${settings.height}`,
'-preset', 'fast',
'-crf', '23',
'-movflags', 'frag_keyframe+empty_moov+faststart',
'-f', 'mp4',
'-g', '60',
'-keyint_min', '60',
'-sc_threshold', '0',
'-pix_fmt', 'yuv420p',
'-profile:v', 'baseline',
'-level', '3.0',
'-map_metadata', '0',
'-map_metadata:s:v', '0:s:v',
'-map_metadata:s:a', '0:s:a',
'-fflags', '+genpts',
'-avoid_negative_ts', 'make_zero'
];
console.log(`[TRANSCODE] FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}`);
const ffmpegCommand = ffmpeg(filePath)
.format('mp4')
.videoCodec('libx264')
@ -94,7 +129,6 @@ export async function GET(
'-preset', 'fast',
'-crf', '23',
'-movflags', 'frag_keyframe+empty_moov+faststart',
'-f', 'mp4',
'-g', '60',
'-keyint_min', '60',
'-sc_threshold', '0',
@ -130,30 +164,22 @@ export async function GET(
// Create a readable stream
const stream = ffmpegCommand.pipe();
// Track FFmpeg process for cleanup
// Track FFmpeg process for cleanup with seek support
let ffmpegProcess: any = null;
let processId = `transcode_${id}_${Date.now()}`;
let processId = `transcode_${id}_${seekTime}_${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);
// Register process with enhanced registry
if (ffmpegProcess) {
ffmpegRegistry.register(id, seekTime, ffmpegProcess, ffmpegArgs, quality);
console.log(`[TRANSCODE] Registered FFmpeg process for video ${id} with seek ${seekTime}s`);
}
});
// Set response headers for streaming with duration
// Set response headers for streaming with duration and seek info
const headers = new Headers({
'Content-Type': 'video/mp4',
'Cache-Control': 'no-cache, no-store, must-revalidate',
@ -163,6 +189,7 @@ export async function GET(
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'X-Content-Duration': duration.toString(),
'X-Seek-Time': seekTime.toString(),
'X-Content-Type-Options': 'nosniff',
// Add additional headers for better streaming
'Accept-Ranges': 'bytes',
@ -172,14 +199,22 @@ export async function GET(
// 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`);
console.log(`[TRANSCODE] Sending response with ${settings.width}x${settings.height} video stream, duration: ${duration}s, seek: ${seekTime}s`);
// Create response
// Create response with cleanup
const response = new Response(readableStream, {
status: 200,
headers,
});
// Cleanup when response is closed
response.body?.getReader().closed.then(() => {
console.log(`[TRANSCODE] Response closed, cleaning up processes for video ${id}`);
ffmpegRegistry.killAllForVideo(id);
}).catch((error) => {
console.error(`[TRANSCODE] Error during response cleanup:`, error);
});
return response;
} catch (error) {
@ -199,8 +234,9 @@ export async function DELETE(
try {
const { id } = await params;
// Use process manager to cleanup all processes for this video ID
processManager.removeByVideoId(id);
// Use enhanced registry to cleanup all processes for this video ID
const killedCount = ffmpegRegistry.killAllForVideo(id);
console.log(`[CLEANUP] Killed ${killedCount} processes for video: ${id}`);
return NextResponse.json({ success: true });
} catch (error) {

View File

@ -4,6 +4,8 @@ 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;
@ -53,13 +55,35 @@ 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 videoRef = useRef<HTMLVideoElement>(null);
const videoRef = useRef<HTMLVideoElement>(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);
// Heartbeat mechanism
const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
@ -139,6 +163,10 @@ export default function VideoViewer({
const videoId = getVideoId();
if (!videoId) return;
// Reset hooks for new video
resetProgress();
// Let the useProtectedDuration hook handle duration fetching internally
videoRef.current.src = `/api/stream/${videoId}`;
videoRef.current.load();
@ -163,41 +191,24 @@ export default function VideoViewer({
}
};
// Handle metadata loaded to get duration
// Handle metadata loaded to get duration (with protection)
const handleLoadedMetadata = () => {
if (videoRef.current) {
const videoDuration = videoRef.current.duration;
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
console.log(`[PLAYER] Duration from metadata: ${videoDuration}s`);
setDuration(videoDuration);
console.log(`[PLAYER] Metadata duration: ${videoDuration}s`);
protectedHandleDurationChange(videoDuration);
}
}
};
// 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
// Handle duration change events (with protection)
const handleDurationChange = () => {
if (videoRef.current) {
const videoDuration = videoRef.current.duration;
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
console.log(`[PLAYER] Duration changed: ${videoDuration}s`);
setDuration(videoDuration);
console.log(`[PLAYER] Duration change: ${videoDuration}s`);
protectedHandleDurationChange(videoDuration);
}
}
};
@ -207,9 +218,6 @@ 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);
@ -224,31 +232,30 @@ export default function VideoViewer({
}
}, [isOpen, video, isTranscoding]);
// Fetch duration when transcoding state changes
// Separate effect for hook event listeners to avoid infinite re-renders
useEffect(() => {
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);
}
}
} catch (error) {
console.log('Could not fetch transcoded duration:', error);
}
};
if (!isOpen || !videoRef.current) return;
fetchTranscodedDuration();
}
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
}
}, [isTranscoding, video]);
}, [isOpen, video]); // Remove function dependencies
// Keyboard shortcuts
useEffect(() => {
@ -323,26 +330,24 @@ export default function VideoViewer({
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
const videoDuration = videoRef.current.duration;
if (videoDuration && videoDuration > 0) {
setDuration(videoDuration);
}
}
};
const handleSeek = (newTime: number) => {
const videoId = getVideoId();
if (!videoId || !videoRef.current) return;
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseFloat(e.target.value);
if (videoRef.current) {
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
// For transcoded videos, use seek-optimized transcoding
if (isTranscoding) {
console.log(`[PLAYER] Seek-optimized transcoding to ${newTime}s`);
// Kill current transcoding process
fetch(`/api/stream/${videoId}`, { method: 'DELETE' });
// Start new transcoding with seek parameter
videoRef.current.src = `/api/stream/${videoId}/transcode?seek=${newTime}`;
videoRef.current.load();
} else {
// Direct video seeking
stableHandleSeek(newTime);
}
};
@ -415,11 +420,6 @@ 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;
@ -451,8 +451,6 @@ 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)}
@ -468,20 +466,54 @@ 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'}`}>
{/* Progress bar */}
{/* Enhanced Progress bar with buffer visualization */}
<div className="mb-4">
<input
type="range"
min="0"
max={duration || 0}
value={currentTime}
onChange={handleSeek}
className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
/>
<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"
/>
</div>
<div className="flex justify-between text-white text-sm mt-1">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</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) */}
@ -491,7 +523,9 @@ 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)}</p>
<p className="text-gray-300 text-sm">Duration: {formatTime(duration)}
{isTranscoding && <span className="text-yellow-400 ml-1">(Transcoded)</span>}
</p>
)}
</div>
{(showBookmarks || showRatings) && (

View File

@ -0,0 +1,237 @@
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);
});
}

View File

@ -0,0 +1,175 @@
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;
}
}

View File

@ -0,0 +1,283 @@
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;
// Throttle updates to prevent excessive re-renders
if (now - lastUpdateTime.current < configRef.current.updateThrottle) {
return;
}
// Allow forward progress or small backward adjustments
if (rawTime >= lastStableTime.current - configRef.current.smallAdjustmentThreshold) {
setCurrentTime(rawTime);
lastStableTime.current = rawTime;
lastUpdateTime.current = now;
} else {
// Block large backward jumps (jitter prevention)
console.log(`[ANTI-JITTER] Blocked backward jump: ${rawTime.toFixed(2)}s -> ${lastStableTime.current.toFixed(2)}s (threshold: ${configRef.current.jitterThreshold}s)`);
// Optionally clamp to last stable time to prevent visual jumping
if (Math.abs(rawTime - lastStableTime.current) > configRef.current.jitterThreshold) {
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 => {
// Use functional updates to avoid dependencies on current state
setCurrentTime(() => 0);
setBufferState(() => ({ buffered: 0, lastBufferUpdate: 0 }));
lastStableTime.current = 0;
lastUpdateTime.current = 0;
isDraggingRef.current = false;
setIsDragging(() => false);
}, []); // 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 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
}
};