feat(transcode): improve transcoding logic with request deduplication and stable FFmpeg processes

- Add HEAD request handler to serve video metadata without body
- Replace fluent-ffmpeg with direct spawn for FFmpeg process execution
- Implement active request tracking to prevent duplicate transcoding streams
- Enhance process management by avoiding killing similar seek processes (within 2s)
- Preserve original metadata and apply duration overrides for accurate streaming
- Return full duration metadata and seek time in custom response headers
- Add DELETE endpoint to clean up all transcoding processes for a video
- Update client video player to detect transcoding need and switch source accordingly
- Implement retry logic with capped attempts for both direct and transcoding streams
- Add user-visible transcoding error display with retry button in video viewer
- Optimize transcoding seek requests to avoid duplicate or unnecessary restarts
- Enhance useStableProgress hook to support seek offset from streaming URL
- Adjust progress updates to consider seek time offset for smooth playback progress
- Initialize progress state at seek offset instead of zero on reset to prevent jitter
This commit is contained in:
tigeren 2025-09-06 18:26:31 +00:00
parent 13d6874c00
commit a22e4a95c5
3 changed files with 369 additions and 126 deletions

View File

@ -1,10 +1,61 @@
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 ffmpeg from 'fluent-ffmpeg'; import { spawn } from 'child_process';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry'; import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry';
// Track active requests to prevent duplicate processing
const activeRequests = new Map<string, Promise<Response>>();
export async function HEAD(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
// Handle HEAD requests by returning just headers without body
try {
const { id } = await params;
const db = getDatabase();
const media = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(id, 'video') as { path: string, codec_info: string } | undefined;
if (!media) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
}
// Get duration from stored codec_info
let duration = 0;
try {
const codecInfo = JSON.parse(media.codec_info || '{}');
duration = codecInfo.duration || 0;
} catch (error) {
// Skip ffprobe fallback for HEAD requests
}
const searchParams = request.nextUrl.searchParams;
const seekTime = parseFloat(searchParams.get('seek') || '0');
const headers = new Headers({
'Content-Type': 'video/mp4',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'X-Content-Duration': duration.toString(),
'X-Seek-Time': seekTime.toString(),
'X-Transcoded': 'true',
});
return new Response(null, {
status: 200,
headers,
});
} catch (error) {
console.error('HEAD request error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function OPTIONS( export async function OPTIONS(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
@ -28,8 +79,6 @@ 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) {
@ -48,19 +97,7 @@ 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);
// Fallback to ffprobe console.log(`[TRANSCODE] Using default duration: 0s`);
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
@ -72,11 +109,9 @@ export async function GET(
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 seek = searchParams.get('seek') || '0';
const retry = searchParams.get('retry') || '0';
const seekTime = parseFloat(seek); const seekTime = parseFloat(seek);
const retryCount = parseInt(retry);
// Kill existing processes for this video (Stash-like behavior)
console.log(`[TRANSCODE] Killing existing processes for video ${id}`);
ffmpegRegistry.killAllForVideo(id);
// Configure transcoding based on quality // Configure transcoding based on quality
const qualitySettings = { const qualitySettings = {
@ -86,15 +121,90 @@ 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 // Build FFmpeg command with seek support (STASH-LIKE: -ss before -i for faster seeking)
// Important: Don't use -t parameter to preserve full duration metadata
const ffmpegArgs = [ const ffmpegArgs = [
'-hide_banner', '-hide_banner',
'-v', 'error', '-v', 'error',
...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek support like Stash ...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek BEFORE input (faster)
'-i', filePath, '-i', filePath,
'-c:v', 'libx264', '-c:v', 'libx264',
'-c:a', 'aac', '-c:a', 'aac',
@ -110,76 +220,62 @@ export async function GET(
'-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
'-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',
'-fflags', '+genpts', '-fflags', '+genpts',
'-avoid_negative_ts', 'make_zero' '-avoid_negative_ts', 'make_zero',
// Add duration override to ensure correct metadata
...(duration > 0 ? ['-metadata', `duration=${duration}`] : []),
'pipe:1'
]; ];
console.log(`[TRANSCODE] FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}`); console.log(`[TRANSCODE] FFmpeg command (Stash-like): ffmpeg ${ffmpegArgs.join(' ')}`);
const ffmpegCommand = ffmpeg(filePath) // Use direct spawn like Stash (not fluent-ffmpeg)
.format('mp4') const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, {
.videoCodec('libx264') stdio: ['ignore', 'pipe', 'pipe']
.audioCodec('aac') });
.videoBitrate(settings.bitrate)
.size(`${settings.width}x${settings.height}`)
.outputOptions([
'-preset', 'fast',
'-crf', '23',
'-movflags', 'frag_keyframe+empty_moov+faststart',
'-g', '60',
'-keyint_min', '60',
'-sc_threshold', '0',
'-pix_fmt', 'yuv420p',
'-profile:v', 'baseline',
'-level', '3.0',
// Ensure proper duration metadata
'-map_metadata', '0',
'-map_metadata:s:v', '0:s:v',
'-map_metadata:s:a', '0:s:a',
// Force duration to be preserved
'-fflags', '+genpts',
'-avoid_negative_ts', 'make_zero',
// Ensure proper streaming
'-frag_duration', '1000000',
'-frag_size', '1000000'
])
.on('start', (commandLine) => {
console.log(`[TRANSCODE] FFmpeg started: ${commandLine}`);
})
.on('error', (err, stdout, stderr) => {
console.error(`[TRANSCODE] FFmpeg error:`, err.message);
console.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}%`);
});
// Create a readable stream // Register process immediately
const stream = ffmpegCommand.pipe(); ffmpegRegistry.register(id, seekTime, ffmpegProcess, ffmpegArgs, quality);
console.log(`[TRANSCODE] Registered FFmpeg process for video ${id} with seek ${seekTime}s`);
// Track FFmpeg process for cleanup with seek support // Handle process events
let ffmpegProcess: any = null; ffmpegProcess.on('error', (err) => {
let processId = `transcode_${id}_${seekTime}_${Date.now()}`; console.error(`[TRANSCODE] FFmpeg error:`, err.message);
console.log(`[TRANSCODE] FFmpeg errored for ${id}_${seekTime}_${quality}, cleaning up`);
ffmpegCommand.on('start', (commandLine) => { });
// Store process reference for cleanup
ffmpegProcess = (ffmpegCommand as any).ffmpegProc; ffmpegProcess.on('exit', (code, signal) => {
if (signal) {
// Register process with enhanced registry console.log(`[TRANSCODE] FFmpeg process killed with signal: ${signal}`);
if (ffmpegProcess) { } else {
ffmpegRegistry.register(id, seekTime, ffmpegProcess, ffmpegArgs, quality); console.log(`[TRANSCODE] FFmpeg process exited with code: ${code}`);
console.log(`[TRANSCODE] Registered FFmpeg process for video ${id} with seek ${seekTime}s`);
} }
}); });
// Set response headers for streaming with duration and seek info // Handle stderr for progress and errors
ffmpegProcess.stderr?.on('data', (data) => {
const output = data.toString();
if (output.includes('time=')) {
// Parse time for progress calculation
const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
if (timeMatch && duration > 0) {
const [, hours, minutes, seconds, centiseconds] = timeMatch;
const currentTime = parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100;
const totalDuration = duration - seekTime; // Remaining duration from seek point
const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0;
console.log(`[TRANSCODE] Progress: ${progress.toFixed(2)}%`);
}
} else if (output.includes('error') || output.includes('Error')) {
console.error(`[TRANSCODE] FFmpeg stderr:`, output.trim());
}
});
// Set response headers for streaming with proper duration info
// Always use the stored duration, not the seek-adjusted duration
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',
@ -188,41 +284,31 @@ export async function GET(
'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(), 'X-Content-Duration': duration.toString(), // Always full duration
'X-Seek-Time': seekTime.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 // Convert Node.js stream to Web Stream for Next.js (use stdout directly)
const readableStream = Readable.toWeb(stream as any) as ReadableStream; const readableStream = Readable.toWeb(ffmpegProcess.stdout 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, seek: ${seekTime}s`);
// Create response with cleanup // Create response with direct stream (no caching like Stash)
const response = new Response(readableStream, { const response = new Response(readableStream, {
status: 200, status: 200,
headers, headers,
}); });
// Cleanup when response is closed
response.body?.getReader().closed.then(() => {
console.log(`[TRANSCODE] Response closed, cleaning up processes for video ${id}`);
ffmpegRegistry.killAllForVideo(id);
}).catch((error) => {
console.error(`[TRANSCODE] Error during response cleanup:`, error);
});
return response; return response;
} catch (error) { } catch (error) {
console.error('Transcoding API error:', error); console.error('Transcode stream creation error:', error);
return NextResponse.json( throw error;
{ error: 'Internal server error' },
{ status: 500 }
);
} }
} }
@ -234,11 +320,11 @@ 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 // Use enhanced registry to cleanup all processes for this video ID (Stash-like)
const killedCount = ffmpegRegistry.killAllForVideo(id); const killedCount = ffmpegRegistry.killAllForVideo(id);
console.log(`[CLEANUP] Killed ${killedCount} processes for video: ${id}`); console.log(`[CLEANUP] Killed ${killedCount} processes for video: ${id}`);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true, killedProcesses: killedCount });
} 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

@ -59,7 +59,10 @@ export default function VideoViewer({
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 [retryCount, setRetryCount] = useState(0);
const videoRef = useRef<HTMLVideoElement>(null!); const videoRef = useRef<HTMLVideoElement>(null!);
const lastTranscodingUrlRef = useRef<string | null>(null);
// Use protected duration hook for accurate duration display // Use protected duration hook for accurate duration display
const { const {
@ -167,22 +170,104 @@ export default function VideoViewer({
resetProgress(); resetProgress();
// Let the useProtectedDuration hook handle duration fetching internally // Let the useProtectedDuration hook handle duration fetching internally
videoRef.current.src = `/api/stream/${videoId}`; // First check if this video needs transcoding
videoRef.current.load(); 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();
}
};
// Handle video load errors (fallback to transcoding) checkTranscodingNeeded();
const handleError = () => {
console.log('Video load failed, trying transcoded version...'); // Handle video load errors (simplified since we pre-check transcoding needs)
if (videoRef.current) { 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); setIsTranscoding(true);
videoRef.current.src = `/api/stream/${videoId}/transcode`; setTranscodingError(null);
videoRef.current.load(); 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();
}
}, 1000);
} else if (isAlreadyTranscoding && retryCount < 3) {
console.log('Transcoding error, retrying...');
setRetryCount(prev => prev + 1);
// Clean up and retry transcoding
try {
await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' });
} catch (cleanupError) {
console.log('Cleanup warning (non-critical):', cleanupError);
}
setTimeout(() => {
if (videoRef.current) {
const transcodingUrl = `/api/stream/${videoId}/transcode?retry=${retryCount}`;
lastTranscodingUrlRef.current = transcodingUrl;
videoRef.current.src = transcodingUrl;
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) => {
@ -331,7 +416,7 @@ export default function VideoViewer({
}; };
const handleSeek = (newTime: number) => { const handleSeek = async (newTime: number) => {
const videoId = getVideoId(); const videoId = getVideoId();
if (!videoId || !videoRef.current) return; if (!videoId || !videoRef.current) return;
@ -339,12 +424,29 @@ export default function VideoViewer({
if (isTranscoding) { if (isTranscoding) {
console.log(`[PLAYER] Seek-optimized transcoding to ${newTime}s`); console.log(`[PLAYER] Seek-optimized transcoding to ${newTime}s`);
// Kill current transcoding process // Prevent multiple simultaneous requests
fetch(`/api/stream/${videoId}`, { method: 'DELETE' }); const newTranscodingUrl = `/api/stream/${videoId}/transcode?seek=${newTime}&t=${Date.now()}`;
if (lastTranscodingUrlRef.current === newTranscodingUrl) {
console.log(`[PLAYER] Skipping duplicate transcoding request`);
return;
}
// Start new transcoding with seek parameter try {
videoRef.current.src = `/api/stream/${videoId}/transcode?seek=${newTime}`; // Kill current transcoding process
videoRef.current.load(); await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' });
// Wait a moment to ensure cleanup
await new Promise(resolve => setTimeout(resolve, 500));
// Start new transcoding with seek parameter
lastTranscodingUrlRef.current = newTranscodingUrl;
videoRef.current.src = newTranscodingUrl;
videoRef.current.load();
} catch (error) {
console.error('Failed to cleanup transcoding process:', error);
// Try fallback direct seek
stableHandleSeek(newTime);
}
} else { } else {
// Direct video seeking // Direct video seeking
stableHandleSeek(newTime); stableHandleSeek(newTime);
@ -435,12 +537,38 @@ export default function VideoViewer({
</button> </button>
{/* Transcoding indicator */} {/* Transcoding indicator */}
{isTranscoding && ( {isTranscoding && !transcodingError && (
<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

View File

@ -84,6 +84,10 @@ export function useStableProgress(
const now = Date.now(); const now = Date.now();
const video = videoRef.current; const video = videoRef.current;
const rawTime = video.currentTime; 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 // Throttle updates to prevent excessive re-renders
if (now - lastUpdateTime.current < configRef.current.updateThrottle) { if (now - lastUpdateTime.current < configRef.current.updateThrottle) {
@ -91,17 +95,20 @@ export function useStableProgress(
} }
// Allow forward progress or small backward adjustments // Allow forward progress or small backward adjustments
if (rawTime >= lastStableTime.current - configRef.current.smallAdjustmentThreshold) { if (actualTime >= lastStableTime.current - configRef.current.smallAdjustmentThreshold) {
setCurrentTime(rawTime); setCurrentTime(actualTime);
lastStableTime.current = rawTime; lastStableTime.current = actualTime;
lastUpdateTime.current = now; lastUpdateTime.current = now;
} else { } else {
// Block large backward jumps (jitter prevention) // Block large backward jumps (jitter prevention)
console.log(`[ANTI-JITTER] Blocked backward jump: ${rawTime.toFixed(2)}s -> ${lastStableTime.current.toFixed(2)}s (threshold: ${configRef.current.jitterThreshold}s)`); 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 // Optionally clamp to last stable time to prevent visual jumping
if (Math.abs(rawTime - lastStableTime.current) > configRef.current.jitterThreshold) { if (Math.abs(actualTime - lastStableTime.current) > configRef.current.jitterThreshold) {
video.currentTime = lastStableTime.current; // 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 }, []); // Remove videoRef dependency to prevent re-creation
@ -174,13 +181,20 @@ export function useStableProgress(
* Reset progress state (for video changes) * Reset progress state (for video changes)
*/ */
const resetProgress = useCallback((): void => { 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 // Use functional updates to avoid dependencies on current state
setCurrentTime(() => 0); setCurrentTime(() => seekOffset); // Start from seek offset, not 0
setBufferState(() => ({ buffered: 0, lastBufferUpdate: 0 })); setBufferState(() => ({ buffered: 0, lastBufferUpdate: 0 }));
lastStableTime.current = 0; lastStableTime.current = seekOffset; // Initialize to seek offset
lastUpdateTime.current = 0; lastUpdateTime.current = 0;
isDraggingRef.current = false; isDraggingRef.current = false;
setIsDragging(() => false); setIsDragging(() => false);
if (seekOffset > 0) {
console.log(`[PROGRESS] Reset progress with seek offset: ${seekOffset}s`);
}
}, []); // Empty deps array is safe with functional updates }, []); // Empty deps array is safe with functional updates
// Reset when video source changes // Reset when video source changes
@ -201,6 +215,21 @@ export function useStableProgress(
}; };
} }
/**
* 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 * Utility function to format time for display
*/ */