# Next.js Adaptation Guide - Complete Implementation ## Overview This guide provides comprehensive strategies for implementing Stash's media streaming architecture in a Next.js application, covering process management, progress tracking, and anti-jitter mechanisms. ## Key Architecture Differences | Stash (Go) | Next.js (Node.js) | |------------|-------------------| | `context.WithCancel` | `AbortController` | | `http.Hijacker` | Request events (`close`, `aborted`) | | `exec.Command` | `child_process.spawn` | | `sync.WaitGroup` | `Promise.all` + process tracking | | File locks | Process pool + cleanup | | Graceful shutdown | `SIGTERM` handlers | ## 1. Core Process Management ### AbortController Pattern ```typescript // lib/transcode/abort-controller.ts import { spawn, ChildProcess } from 'child_process'; export class TranscodeController { private controller: AbortController; private process?: ChildProcess; private startTime: Date; private filePath: string; constructor(filePath: string) { this.controller = new AbortController(); this.startTime = new Date(); this.filePath = filePath; } async startTranscode(args: string[]): Promise { const { signal } = this.controller; this.process = spawn('ffmpeg', args, { signal, stdio: ['ignore', 'pipe', 'pipe'], detached: false }); // Handle cleanup on abort signal.addEventListener('abort', () => { this.cleanup(); }); return this.process; } private cleanup() { if (this.process && !this.process.killed) { this.process.kill('SIGKILL'); this.process.unref(); } } abort() { this.controller.abort(); } getUptime(): number { return Date.now() - this.startTime.getTime(); } isRunning(): boolean { return this.process ? !this.process.killed : false; } } ``` ### Process Pool Management ```typescript // lib/transcode/process-pool.ts import { ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; interface ProcessEntry { process: ChildProcess; controller: TranscodeController; startTime: Date; filePath: string; quality: string; } export class TranscodeProcessPool extends EventEmitter { private processes = new Map(); private maxProcesses = parseInt(process.env.MAX_TRANSCODE_PROCESSES || '4'); add( key: string, process: ChildProcess, controller: TranscodeController, filePath: string, quality: string ): boolean { if (this.processes.size >= this.maxProcesses) { return false; } this.processes.set(key, { process, controller, startTime: new Date(), filePath, quality }); // Auto cleanup process.on('exit', () => { this.processes.delete(key); this.emit('process-exit', key); }); this.emit('process-added', key); return true; } kill(key: string): boolean { const entry = this.processes.get(key); if (entry) { entry.controller.abort(); this.processes.delete(key); return true; } return false; } killAll(): void { for (const [key, entry] of this.processes) { entry.controller.abort(); } this.processes.clear(); } getActiveProcesses(): ProcessEntry[] { return Array.from(this.processes.values()); } getStats() { return { active: this.processes.size, max: this.maxProcesses, uptime: Array.from(this.processes.values()).map(p => ({ filePath: p.filePath, quality: p.quality, uptime: Date.now() - p.startTime.getTime() })) }; } } export const transcodePool = new TranscodeProcessPool(); ``` ## 2. API Route Implementation ### Transcode API Route ```typescript // pages/api/transcode/[...slug].ts import { NextApiRequest, NextApiResponse } from 'next'; import { spawn } from 'child_process'; import { TranscodeController } from '../../../lib/transcode/abort-controller'; import { transcodePool } from '../../../lib/transcode/process-pool'; export const config = { runtime: 'nodejs', api: { responseLimit: '500mb', }, }; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { if (req.method !== 'GET') { return res.status(405).json({ error: 'Method not allowed' }); } const { file, quality = '720p', start = '0' } = req.query; if (!file || typeof file !== 'string') { return res.status(400).json({ error: 'File parameter required' }); } const startTime = parseFloat(start as string); const key = `${file}:${quality}:${startTime}`; try { // Create transcode controller const controller = new TranscodeController(file); // Build ffmpeg arguments const args = [ '-i', file, '-ss', startTime.toString(), '-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', '-f', 'mp4', '-movflags', 'frag_keyframe+empty_moov', '-max_muxing_queue_size', '9999', 'pipe:1' ]; // Handle client disconnect req.on('close', () => { controller.abort(); }); req.on('aborted', () => { controller.abort(); }); // Start transcoding const process = await controller.startTranscode(args); // Add to process pool if (!transcodePool.add(key, process, controller, file, quality as string)) { controller.abort(); return res.status(503).json({ error: 'Too many concurrent transcodes' }); } // Set response headers res.setHeader('Content-Type', 'video/mp4'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Access-Control-Allow-Origin', '*'); // Handle range requests const range = req.headers.range; if (range) { const parts = range.replace(/bytes=/, '').split('-'); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : undefined; res.status(206); res.setHeader('Content-Range', `bytes ${start}-${end || '*'}/${'*'}`); } // Stream output process.stdout.pipe(res); // Handle errors process.stderr.on('data', (data) => { console.error(`FFmpeg error: ${data}`); }); process.on('error', (error) => { console.error('Transcoding process error:', error); if (!res.headersSent) { res.status(500).json({ error: 'Transcoding failed' }); } }); } catch (error) { console.error('Transcode error:', error); if (!res.headersSent) { res.status(500).json({ error: 'Internal server error' }); } } } ``` ### HLS Manifest API ```typescript // pages/api/hls/[id].ts import { NextApiRequest, NextApiResponse } from 'next'; import { extractVideoMetadata } from '../../../lib/video/metadata'; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { const { id, quality = '720p' } = req.query; try { const metadata = await extractVideoMetadata(id as string); const segmentDuration = 2; // 2-second segments const totalSegments = Math.ceil(metadata.duration / segmentDuration); const manifest = [ '#EXTM3U', '#EXT-X-VERSION:3', '#EXT-X-TARGETDURATION:2', '#EXT-X-MEDIA-SEQUENCE:0', ...Array.from({ length: totalSegments }, (_, i) => [ `#EXTINF:${Math.min(segmentDuration, metadata.duration - i * segmentDuration).toFixed(3)},`, `/api/hls/${id}/segment/${i}?quality=${quality}` ]).flat(), '#EXT-X-ENDLIST' ].join('\n'); res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); res.setHeader('Cache-Control', 'no-cache'); res.send(manifest); } catch (error) { res.status(500).json({ error: 'Failed to generate manifest' }); } } ``` ## 3. Progress Tracking with Anti-Jitter ### React Hook Implementation ```typescript // hooks/useTranscodeProgress.ts import { useState, useEffect, useRef, useCallback } from 'react'; interface ProgressState { progress: number; buffered: number; isTranscoding: boolean; currentTime: number; duration: number; } interface TranscodeProgressOptions { debounceMs?: number; enableSmoothing?: boolean; maxJitter?: number; } export const useTranscodeProgress = ( videoUrl: string, totalDuration: number, options: TranscodeProgressOptions = {} ) => { const { debounceMs = 100, enableSmoothing = true, maxJitter = 0.01 } = options; const [state, setState] = useState({ progress: 0, buffered: 0, isTranscoding: true, currentTime: 0, duration: totalDuration }); const videoRef = useRef(null); const lastProgressRef = useRef(0); const lastUpdateRef = useRef(Date.now()); const abortControllerRef = useRef(null); const debounce = useCallback((func: Function, wait: number) => { let timeout: NodeJS.Timeout; return (...args: any[]) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; }, []); const updateProgress = useCallback(debounce((newProgress: number) => { const now = Date.now(); const timeSinceLastUpdate = now - lastUpdateRef.current; // Anti-jitter: only update if significant change or time elapsed const progressDiff = Math.abs(newProgress - lastProgressRef.current); const shouldUpdate = progressDiff > maxJitter || timeSinceLastUpdate > 500; if (shouldUpdate) { lastProgressRef.current = newProgress; lastUpdateRef.current = now; setState(prev => ({ ...prev, progress: Math.min(newProgress, 1), currentTime: newProgress * totalDuration })); } }, debounceMs), [debounceMs, maxJitter, totalDuration]); const updateBuffer = useCallback(debounce(() => { const video = videoRef.current; if (!video) return; const buffered = video.buffered; if (buffered.length > 0) { const bufferedEnd = buffered.end(buffered.length - 1); const bufferedRatio = bufferedEnd / totalDuration; setState(prev => ({ ...prev, buffered: Math.min(bufferedRatio, 1) })); } }, 250), [totalDuration]); const checkTranscodingStatus = useCallback(async () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } abortControllerRef.current = new AbortController(); try { const response = await fetch(`/api/transcode/status?url=${encodeURIComponent(videoUrl)}`, { signal: abortControllerRef.current.signal }); if (response.ok) { const data = await response.json(); setState(prev => ({ ...prev, isTranscoding: data.transcoding, duration: data.duration || prev.duration })); } } catch (error) { if (error.name !== 'AbortError') { console.error('Failed to check transcoding status:', error); } } }, [videoUrl]); useEffect(() => { const video = videoRef.current; if (!video) return; const handleTimeUpdate = () => { updateProgress(video.currentTime / totalDuration); }; const handleProgress = () => { updateBuffer(); }; const handleLoadedMetadata = () => { setState(prev => ({ ...prev, duration: video.duration || totalDuration })); }; video.addEventListener('timeupdate', handleTimeUpdate); video.addEventListener('progress', handleProgress); video.addEventListener('loadedmetadata', handleLoadedMetadata); const interval = setInterval(checkTranscodingStatus, 2000); return () => { video.removeEventListener('timeupdate', handleTimeUpdate); video.removeEventListener('progress', handleProgress); video.removeEventListener('loadedmetadata', handleLoadedMetadata); clearInterval(interval); if (abortControllerRef.current) { abortControllerRef.current.abort(); } }; }, [videoRef, totalDuration, updateProgress, updateBuffer, checkTranscodingStatus]); return { ...state, videoRef, retry: checkTranscodingStatus }; }; ``` ## 4. Video Player Component ### Complete Player Implementation ```typescript // components/VideoPlayer.tsx import React, { useRef, useEffect } from 'react'; import { useTranscodeProgress } from '../hooks/useTranscodeProgress'; interface VideoPlayerProps { src: string; poster?: string; autoPlay?: boolean; className?: string; } export const VideoPlayer: React.FC = ({ src, poster, autoPlay = false, className }) => { const { videoRef, progress, buffered, isTranscoding, currentTime, duration, retry } = useTranscodeProgress(src, 0); const handleSeek = (time: number) => { if (videoRef.current) { videoRef.current.currentTime = time; } }; const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; return (