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:
parent
13d6874c00
commit
a22e4a95c5
|
|
@ -1,10 +1,61 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDatabase } from '@/db';
|
||||
import fs from 'fs';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { spawn } from 'child_process';
|
||||
import { Readable } from 'stream';
|
||||
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(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
|
@ -28,8 +79,6 @@ export async function GET(
|
|||
const { id } = await params;
|
||||
const db = getDatabase();
|
||||
|
||||
console.log(`[TRANSCODE] Starting transcoding for video ID: ${id}`);
|
||||
|
||||
// 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;
|
||||
if (!media) {
|
||||
|
|
@ -48,19 +97,7 @@ export async function GET(
|
|||
console.log(`[TRANSCODE] Using stored duration: ${duration}s`);
|
||||
} catch (error) {
|
||||
console.error(`[TRANSCODE] Could not parse codec_info:`, error);
|
||||
// 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);
|
||||
}
|
||||
console.log(`[TRANSCODE] Using default duration: 0s`);
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
|
|
@ -72,11 +109,9 @@ export async function GET(
|
|||
const searchParams = request.nextUrl.searchParams;
|
||||
const quality = searchParams.get('quality') || '720p';
|
||||
const seek = searchParams.get('seek') || '0';
|
||||
const retry = searchParams.get('retry') || '0';
|
||||
const seekTime = parseFloat(seek);
|
||||
|
||||
// Kill existing processes for this video (Stash-like behavior)
|
||||
console.log(`[TRANSCODE] Killing existing processes for video ${id}`);
|
||||
ffmpegRegistry.killAllForVideo(id);
|
||||
const retryCount = parseInt(retry);
|
||||
|
||||
// Configure transcoding based on quality
|
||||
const qualitySettings = {
|
||||
|
|
@ -87,14 +122,89 @@ export async function GET(
|
|||
|
||||
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
|
||||
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 = [
|
||||
'-hide_banner',
|
||||
'-v', 'error',
|
||||
...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek support like Stash
|
||||
...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek BEFORE input (faster)
|
||||
'-i', filePath,
|
||||
'-c:v', 'libx264',
|
||||
'-c:a', 'aac',
|
||||
|
|
@ -110,76 +220,62 @@ export async function GET(
|
|||
'-pix_fmt', 'yuv420p',
|
||||
'-profile:v', 'baseline',
|
||||
'-level', '3.0',
|
||||
// Preserve original metadata to maintain duration info
|
||||
'-map_metadata', '0',
|
||||
'-map_metadata:s:v', '0:s:v',
|
||||
'-map_metadata:s:a', '0:s:a',
|
||||
'-fflags', '+genpts',
|
||||
'-avoid_negative_ts', 'make_zero'
|
||||
];
|
||||
|
||||
console.log(`[TRANSCODE] FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}`);
|
||||
|
||||
const ffmpegCommand = ffmpeg(filePath)
|
||||
.format('mp4')
|
||||
.videoCodec('libx264')
|
||||
.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}%`);
|
||||
// Add duration override to ensure correct metadata
|
||||
...(duration > 0 ? ['-metadata', `duration=${duration}`] : []),
|
||||
'pipe:1'
|
||||
];
|
||||
|
||||
console.log(`[TRANSCODE] FFmpeg command (Stash-like): ffmpeg ${ffmpegArgs.join(' ')}`);
|
||||
|
||||
// Use direct spawn like Stash (not fluent-ffmpeg)
|
||||
const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
// Create a readable stream
|
||||
const stream = ffmpegCommand.pipe();
|
||||
|
||||
// Track FFmpeg process for cleanup with seek support
|
||||
let ffmpegProcess: any = null;
|
||||
let processId = `transcode_${id}_${seekTime}_${Date.now()}`;
|
||||
|
||||
ffmpegCommand.on('start', (commandLine) => {
|
||||
// Store process reference for cleanup
|
||||
ffmpegProcess = (ffmpegCommand as any).ffmpegProc;
|
||||
|
||||
// Register process with enhanced registry
|
||||
if (ffmpegProcess) {
|
||||
// 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.log(`[TRANSCODE] FFmpeg errored for ${id}_${seekTime}_${quality}, cleaning up`);
|
||||
});
|
||||
|
||||
ffmpegProcess.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.log(`[TRANSCODE] FFmpeg process killed with signal: ${signal}`);
|
||||
} else {
|
||||
console.log(`[TRANSCODE] FFmpeg process exited with code: ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 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({
|
||||
'Content-Type': 'video/mp4',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
|
|
@ -188,41 +284,31 @@ export async function GET(
|
|||
'Content-Disposition': 'inline',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'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-Content-Type-Options': 'nosniff',
|
||||
// Add additional headers for better streaming
|
||||
'Accept-Ranges': 'bytes',
|
||||
'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
|
||||
const readableStream = Readable.toWeb(stream as any) as ReadableStream;
|
||||
// Convert Node.js stream to Web Stream for Next.js (use stdout directly)
|
||||
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`);
|
||||
|
||||
// Create response with cleanup
|
||||
// Create response with direct stream (no caching like Stash)
|
||||
const response = new Response(readableStream, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Cleanup when response is closed
|
||||
response.body?.getReader().closed.then(() => {
|
||||
console.log(`[TRANSCODE] Response closed, cleaning up processes for video ${id}`);
|
||||
ffmpegRegistry.killAllForVideo(id);
|
||||
}).catch((error) => {
|
||||
console.error(`[TRANSCODE] Error during response cleanup:`, error);
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Transcoding API error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
console.error('Transcode stream creation error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -234,11 +320,11 @@ export async function DELETE(
|
|||
try {
|
||||
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);
|
||||
console.log(`[CLEANUP] Killed ${killedCount} processes for video: ${id}`);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true, killedProcesses: killedCount });
|
||||
} catch (error) {
|
||||
console.error('Cleanup API error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
|
|
|||
|
|
@ -59,7 +59,10 @@ export default function VideoViewer({
|
|||
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||
const [bookmarkCount, setBookmarkCount] = useState(0);
|
||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||
const [transcodingError, setTranscodingError] = useState<string | null>(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 {
|
||||
|
|
@ -167,22 +170,104 @@ export default function VideoViewer({
|
|||
resetProgress();
|
||||
// Let the useProtectedDuration hook handle duration fetching internally
|
||||
|
||||
videoRef.current.src = `/api/stream/${videoId}`;
|
||||
videoRef.current.load();
|
||||
// First check if this video needs transcoding
|
||||
const checkTranscodingNeeded = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/videos/${videoId}`);
|
||||
const videoData = await response.json();
|
||||
|
||||
// Handle video load errors (fallback to transcoding)
|
||||
const handleError = () => {
|
||||
console.log('Video load failed, trying transcoded version...');
|
||||
if (videoRef.current) {
|
||||
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);
|
||||
videoRef.current.src = `/api/stream/${videoId}/transcode`;
|
||||
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();
|
||||
}
|
||||
}, 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
|
||||
const handleLoadedData = () => {
|
||||
if (videoRef.current) {
|
||||
setTranscodingError(null); // Clear any previous errors
|
||||
setRetryCount(0); // Reset retry count on successful load
|
||||
|
||||
videoRef.current.play().then(() => {
|
||||
setIsPlaying(true);
|
||||
}).catch((error) => {
|
||||
|
|
@ -331,7 +416,7 @@ export default function VideoViewer({
|
|||
};
|
||||
|
||||
|
||||
const handleSeek = (newTime: number) => {
|
||||
const handleSeek = async (newTime: number) => {
|
||||
const videoId = getVideoId();
|
||||
if (!videoId || !videoRef.current) return;
|
||||
|
||||
|
|
@ -339,12 +424,29 @@ export default function VideoViewer({
|
|||
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 {
|
||||
// Kill current transcoding process
|
||||
fetch(`/api/stream/${videoId}`, { method: 'DELETE' });
|
||||
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
|
||||
videoRef.current.src = `/api/stream/${videoId}/transcode?seek=${newTime}`;
|
||||
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);
|
||||
|
|
@ -435,13 +537,39 @@ export default function VideoViewer({
|
|||
</button>
|
||||
|
||||
{/* 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="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm">Transcoding</span>
|
||||
</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 */}
|
||||
<div
|
||||
className="relative w-full h-full bg-black rounded-lg overflow-hidden"
|
||||
|
|
|
|||
|
|
@ -85,25 +85,32 @@ export function useStableProgress(
|
|||
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 (rawTime >= lastStableTime.current - configRef.current.smallAdjustmentThreshold) {
|
||||
setCurrentTime(rawTime);
|
||||
lastStableTime.current = rawTime;
|
||||
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: ${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
|
||||
if (Math.abs(rawTime - lastStableTime.current) > configRef.current.jitterThreshold) {
|
||||
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
|
||||
|
||||
/**
|
||||
|
|
@ -174,13 +181,20 @@ export function useStableProgress(
|
|||
* 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(() => 0);
|
||||
setCurrentTime(() => seekOffset); // Start from seek offset, not 0
|
||||
setBufferState(() => ({ buffered: 0, lastBufferUpdate: 0 }));
|
||||
lastStableTime.current = 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
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue