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

15 KiB

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

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

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

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

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

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

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

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

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

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