Compare commits

..

No commits in common. "a22e4a95c537808e11dfcc8b10048fb96c81e0c0" and "b93bd268259afe19c890b1d1d7b43c1ccd658812" have entirely different histories.

11 changed files with 196 additions and 2441 deletions

Binary file not shown.

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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 #

View File

@ -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`;
}
}

View File

@ -1,60 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/db'; import { getDatabase } from '@/db';
import fs from 'fs'; import fs from 'fs';
import { spawn } from 'child_process'; import ffmpeg from 'fluent-ffmpeg';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry'; import { processManager } from '@/lib/process-manager';
// 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 });
}
}
export async function OPTIONS( export async function OPTIONS(
request: NextRequest, request: NextRequest,
@ -79,6 +28,8 @@ export async function GET(
const { id } = await params; const { id } = await params;
const db = getDatabase(); const db = getDatabase();
console.log(`[TRANSCODE] Starting transcoding for video ID: ${id}`);
// Get media file info with codec_info // 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; const media = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(id, 'video') as { path: string, codec_info: string } | undefined;
if (!media) { if (!media) {
@ -97,7 +48,19 @@ export async function GET(
console.log(`[TRANSCODE] Using stored duration: ${duration}s`); console.log(`[TRANSCODE] Using stored duration: ${duration}s`);
} catch (error) { } catch (error) {
console.error(`[TRANSCODE] Could not parse codec_info:`, 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 // Check if file exists
@ -105,13 +68,9 @@ export async function GET(
return NextResponse.json({ error: 'File not found' }, { status: 404 }); return NextResponse.json({ error: 'File not found' }, { status: 404 });
} }
// Get parameters // Get quality parameter
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const quality = searchParams.get('quality') || '720p'; 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 // Configure transcoding based on quality
const qualitySettings = { const qualitySettings = {
@ -122,94 +81,16 @@ export async function GET(
const settings = qualitySettings[quality as keyof typeof qualitySettings] || qualitySettings['720p']; 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 // Create a readable stream from FFmpeg
console.log(`[TRANSCODE] Starting FFmpeg process for: ${filePath}`); console.log(`[TRANSCODE] Starting FFmpeg process for: ${filePath}`);
// Build FFmpeg command with seek support (STASH-LIKE: -ss before -i for faster seeking) const ffmpegCommand = ffmpeg(filePath)
// Important: Don't use -t parameter to preserve full duration metadata .format('mp4')
const ffmpegArgs = [ .videoCodec('libx264')
'-hide_banner', .audioCodec('aac')
'-v', 'error', .videoBitrate(settings.bitrate)
...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek BEFORE input (faster) .size(`${settings.width}x${settings.height}`)
'-i', filePath, .outputOptions([
'-c:v', 'libx264',
'-c:a', 'aac',
'-b:v', settings.bitrate,
'-s', `${settings.width}x${settings.height}`,
'-preset', 'fast', '-preset', 'fast',
'-crf', '23', '-crf', '23',
'-movflags', 'frag_keyframe+empty_moov+faststart', '-movflags', 'frag_keyframe+empty_moov+faststart',
@ -220,62 +101,59 @@ async function createTranscodeStream(
'-pix_fmt', 'yuv420p', '-pix_fmt', 'yuv420p',
'-profile:v', 'baseline', '-profile:v', 'baseline',
'-level', '3.0', '-level', '3.0',
// Preserve original metadata to maintain duration info // Ensure proper duration metadata
'-map_metadata', '0', '-map_metadata', '0',
'-map_metadata:s:v', '0:s:v', '-map_metadata:s:v', '0:s:v',
'-map_metadata:s:a', '0:s:a', '-map_metadata:s:a', '0:s:a',
// Force duration to be preserved
'-fflags', '+genpts', '-fflags', '+genpts',
'-avoid_negative_ts', 'make_zero', '-avoid_negative_ts', 'make_zero',
// Add duration override to ensure correct metadata // Ensure proper streaming
...(duration > 0 ? ['-metadata', `duration=${duration}`] : []), '-frag_duration', '1000000',
'pipe:1' '-frag_size', '1000000'
]; ])
.on('start', (commandLine) => {
console.log(`[TRANSCODE] FFmpeg command (Stash-like): ffmpeg ${ffmpegArgs.join(' ')}`); console.log(`[TRANSCODE] FFmpeg started: ${commandLine}`);
})
// Use direct spawn like Stash (not fluent-ffmpeg) .on('error', (err, stdout, stderr) => {
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) => {
console.error(`[TRANSCODE] FFmpeg error:`, err.message); 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) => { // Create a readable stream
if (signal) { const stream = ffmpegCommand.pipe();
console.log(`[TRANSCODE] FFmpeg process killed with signal: ${signal}`);
} else { // Track FFmpeg process for cleanup
console.log(`[TRANSCODE] FFmpeg process exited with code: ${code}`); 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 // Set response headers for streaming with duration
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
const headers = new Headers({ const headers = new Headers({
'Content-Type': 'video/mp4', 'Content-Type': 'video/mp4',
'Cache-Control': 'no-cache, no-store, must-revalidate', 'Cache-Control': 'no-cache, no-store, must-revalidate',
@ -284,31 +162,32 @@ async function createTranscodeStream(
'Content-Disposition': 'inline', 'Content-Disposition': 'inline',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'X-Content-Duration': duration.toString(), // Always full duration 'X-Content-Duration': duration.toString(),
'X-Seek-Time': seekTime.toString(),
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
// Add additional headers for better streaming
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
'X-Transcoded': 'true', '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) // Convert Node.js stream to Web Stream for Next.js
const readableStream = Readable.toWeb(ffmpegProcess.stdout as any) as ReadableStream; 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, { const response = new Response(readableStream, {
status: 200, status: 200,
headers, headers,
}); });
return response; return response;
} catch (error) { } catch (error) {
console.error('Transcode stream creation error:', error); console.error('Transcoding API error:', error);
throw error; return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
} }
} }
@ -320,11 +199,10 @@ export async function DELETE(
try { try {
const { id } = await params; const { id } = await params;
// Use enhanced registry to cleanup all processes for this video ID (Stash-like) // Use process manager to cleanup all processes for this video ID
const killedCount = ffmpegRegistry.killAllForVideo(id); processManager.removeByVideoId(id);
console.log(`[CLEANUP] Killed ${killedCount} processes for video: ${id}`);
return NextResponse.json({ success: true, killedProcesses: killedCount }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Cleanup API error:', error); console.error('Cleanup API error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });

View File

@ -4,8 +4,6 @@ import { useState, useEffect, useRef } from 'react';
import { X, Play, Pause, Volume2, VolumeX, Maximize, Star, Bookmark } from 'lucide-react'; import { X, Play, Pause, Volume2, VolumeX, Maximize, Star, Bookmark } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useProtectedDuration } from '@/lib/hooks/use-protected-duration';
import { useStableProgress, formatTime } from '@/lib/hooks/use-stable-progress';
interface Video { interface Video {
id: number; id: number;
@ -55,38 +53,13 @@ export default function VideoViewer({
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1); const [volume, setVolume] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [showControls, setShowControls] = useState(true); const [showControls, setShowControls] = useState(true);
const [isBookmarked, setIsBookmarked] = useState(false); const [isBookmarked, setIsBookmarked] = useState(false);
const [bookmarkCount, setBookmarkCount] = useState(0); const [bookmarkCount, setBookmarkCount] = useState(0);
const [isTranscoding, setIsTranscoding] = useState(false); const [isTranscoding, setIsTranscoding] = useState(false);
const [transcodingError, setTranscodingError] = useState<string | null>(null); const videoRef = useRef<HTMLVideoElement>(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);
// Heartbeat mechanism // Heartbeat mechanism
const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
@ -166,108 +139,22 @@ export default function VideoViewer({
const videoId = getVideoId(); const videoId = getVideoId();
if (!videoId) return; if (!videoId) return;
// Reset hooks for new video videoRef.current.src = `/api/stream/${videoId}`;
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.load(); videoRef.current.load();
}
}, 1000);
} else if (isAlreadyTranscoding && retryCount < 3) {
console.log('Transcoding error, retrying...');
setRetryCount(prev => prev + 1);
// Clean up and retry transcoding // Handle video load errors (fallback to transcoding)
try { const handleError = () => {
await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' }); console.log('Video load failed, trying transcoded version...');
} catch (cleanupError) {
console.log('Cleanup warning (non-critical):', cleanupError);
}
setTimeout(() => {
if (videoRef.current) { if (videoRef.current) {
const transcodingUrl = `/api/stream/${videoId}/transcode?retry=${retryCount}`; setIsTranscoding(true);
lastTranscodingUrlRef.current = transcodingUrl; videoRef.current.src = `/api/stream/${videoId}/transcode`;
videoRef.current.src = transcodingUrl;
videoRef.current.load(); 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 // Auto-play when video is loaded
const handleLoadedData = () => { const handleLoadedData = () => {
if (videoRef.current) { if (videoRef.current) {
setTranscodingError(null); // Clear any previous errors
setRetryCount(0); // Reset retry count on successful load
videoRef.current.play().then(() => { videoRef.current.play().then(() => {
setIsPlaying(true); setIsPlaying(true);
}).catch((error) => { }).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 = () => { const handleLoadedMetadata = () => {
if (videoRef.current) { if (videoRef.current) {
const videoDuration = videoRef.current.duration; const videoDuration = videoRef.current.duration;
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) { if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
console.log(`[PLAYER] Metadata duration: ${videoDuration}s`); console.log(`[PLAYER] Duration from metadata: ${videoDuration}s`);
protectedHandleDurationChange(videoDuration); 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 = () => { const handleDurationChange = () => {
if (videoRef.current) { if (videoRef.current) {
const videoDuration = videoRef.current.duration; const videoDuration = videoRef.current.duration;
if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) { if (videoDuration && videoDuration > 0 && !isNaN(videoDuration)) {
console.log(`[PLAYER] Duration change: ${videoDuration}s`); console.log(`[PLAYER] Duration changed: ${videoDuration}s`);
protectedHandleDurationChange(videoDuration); setDuration(videoDuration);
} }
} }
}; };
@ -303,6 +207,9 @@ export default function VideoViewer({
videoRef.current.addEventListener('durationchange', handleDurationChange); videoRef.current.addEventListener('durationchange', handleDurationChange);
videoRef.current.addEventListener('error', handleError); videoRef.current.addEventListener('error', handleError);
// Try to get duration from headers
handleResponseHeaders();
return () => { return () => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.removeEventListener('loadeddata', handleLoadedData); videoRef.current.removeEventListener('loadeddata', handleLoadedData);
@ -317,30 +224,31 @@ export default function VideoViewer({
} }
}, [isOpen, video, isTranscoding]); }, [isOpen, video, isTranscoding]);
// Separate effect for hook event listeners to avoid infinite re-renders // Fetch duration when transcoding state changes
useEffect(() => { useEffect(() => {
if (!isOpen || !videoRef.current) return; if (isTranscoding) {
const videoId = getVideoId();
const video = videoRef.current; if (videoId) {
const fetchTranscodedDuration = async () => {
// Add event listeners for the hooks try {
video.addEventListener('timeupdate', stableHandleTimeUpdate); const response = await fetch(`/api/stream/${videoId}/transcode`);
video.addEventListener('progress', stableHandleProgress); const contentDuration = response.headers.get('X-Content-Duration');
if (contentDuration) {
return () => { const durationValue = parseFloat(contentDuration);
video.removeEventListener('timeupdate', stableHandleTimeUpdate); if (durationValue > 0 && !isNaN(durationValue)) {
video.removeEventListener('progress', stableHandleProgress); console.log(`[PLAYER] Transcoding duration: ${durationValue}s`);
}; setDuration(durationValue);
}, [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
} }
}, [isOpen, video]); // Remove function dependencies }
} catch (error) {
console.log('Could not fetch transcoded duration:', error);
}
};
fetchTranscodedDuration();
}
}
}, [isTranscoding, video]);
// Keyboard shortcuts // Keyboard shortcuts
useEffect(() => { useEffect(() => {
@ -415,41 +323,26 @@ export default function VideoViewer({
} }
}; };
const handleTimeUpdate = () => {
const handleSeek = async (newTime: number) => { if (videoRef.current) {
const videoId = getVideoId(); setCurrentTime(videoRef.current.currentTime);
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;
} }
};
try { const handleLoadedMetadata = () => {
// Kill current transcoding process if (videoRef.current) {
await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' }); const videoDuration = videoRef.current.duration;
if (videoDuration && videoDuration > 0) {
// Wait a moment to ensure cleanup setDuration(videoDuration);
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);
} }
} 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; 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; if (!isOpen || typeof window === 'undefined') return null;
@ -537,39 +435,13 @@ export default function VideoViewer({
</button> </button>
{/* Transcoding indicator */} {/* 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="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> <div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
<span className="text-sm">Transcoding</span> <span className="text-sm">Transcoding</span>
</div> </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 */} {/* Video container */}
<div <div
className="relative w-full h-full bg-black rounded-lg overflow-hidden" className="relative w-full h-full bg-black rounded-lg overflow-hidden"
@ -579,6 +451,8 @@ export default function VideoViewer({
<video <video
ref={videoRef} ref={videoRef}
className="w-full h-full object-contain" className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={() => setIsPlaying(true)} onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)} onPause={() => setIsPlaying(false)}
onMouseMove={() => setShowControls(true)} onMouseMove={() => setShowControls(true)}
@ -594,54 +468,20 @@ export default function VideoViewer({
{/* Controls overlay */} {/* 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'}`}> <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="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 <input
type="range" type="range"
min="0" min="0"
max={duration || 0} max={duration || 0}
value={currentTime} value={currentTime}
onChange={(e) => handleSeek(parseFloat(e.target.value))} onChange={handleSeek}
onMouseDown={stableHandleSeekStart} className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
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"> <div className="flex justify-between text-white text-sm mt-1">
<span>{formatTime(currentTime)}</span> <span>{formatTime(currentTime)}</span>
{isDurationLoading ? (
<span className="text-gray-400">Loading...</span>
) : (
<span>{formatTime(duration)}</span> <span>{formatTime(duration)}</span>
)}
</div> </div>
{/* Buffer status */}
{bufferState.buffered > 0 && (
<div className="text-xs text-blue-300 mt-1">
Buffered: {formatTime(bufferState.buffered)}
</div>
)}
</div> </div>
{/* Video Info Bar (similar to photo viewer) */} {/* Video Info Bar (similar to photo viewer) */}
@ -651,9 +491,7 @@ export default function VideoViewer({
<h3 className="text-white font-medium">{getVideoTitle()}</h3> <h3 className="text-white font-medium">{getVideoTitle()}</h3>
<p className="text-gray-300 text-sm">{getVideoSize()}</p> <p className="text-gray-300 text-sm">{getVideoSize()}</p>
{duration > 0 && ( {duration > 0 && (
<p className="text-gray-300 text-sm">Duration: {formatTime(duration)} <p className="text-gray-300 text-sm">Duration: {formatTime(duration)}</p>
{isTranscoding && <span className="text-yellow-400 ml-1">(Transcoded)</span>}
</p>
)} )}
</div> </div>
{(showBookmarks || showRatings) && ( {(showBookmarks || showRatings) && (

View File

@ -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);
});
}

View File

@ -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;
}
}

View File

@ -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
}
};