UNPKG

video-to-gif

Version:

A Node.js module that searches YouTube videos by song name and converts clips to animated GIFs

221 lines (196 loc) 6.71 kB
import ffmpeg from 'fluent-ffmpeg'; import path from 'path'; import fs from 'fs'; /** * Extract a clip from the middle of a video and convert it to GIF * @param {string} inputPath - Path to the input video file * @param {string} outputDir - Directory to save the GIF * @param {number} duration - Duration of the clip in seconds * @param {string} videoTitle - Original video title for naming * @returns {Promise<string>} Path to the generated GIF */ async function extractAndConvertToGif(inputPath, outputDir, duration = 10, videoTitle = 'video') { try { // Get video metadata first const metadata = await getVideoMetadata(inputPath); if (!metadata.duration) { console.log('Could not determine video duration'); } // Calculate start time (middle of video minus half the clip duration) const startTime = Math.max(0, (metadata.duration / 2) - (duration / 2)); // Generate output filename const sanitizedTitle = sanitizeFilename(videoTitle); const outputFilename = `${sanitizedTitle}_${duration}s_clip.gif`; const outputPath = path.join(outputDir, outputFilename); // Remove existing file if it exists if (fs.existsSync(outputPath)) { fs.unlinkSync(outputPath); } // Create the GIF await createGif(inputPath, outputPath, startTime, duration); // Verify the GIF was created if (!fs.existsSync(outputPath)) { console.log('GIF file was not created'); } return outputPath; } catch (error) { console.log('❌ Video processing failed:', error.message); console.log(`Failed to process video: ${error.message}`); return null; } } /** * Get video metadata using FFmpeg * @param {string} videoPath - Path to the video file * @returns {Promise<Object>} Video metadata */ function getVideoMetadata(videoPath) { return new Promise((resolve, reject) => { ffmpeg.ffprobe(videoPath, (err, metadata) => { if (err) { console.log(`Failed to read video metadata: ${err.message}`); return null; } try { const videoStream = metadata.streams.find(stream => stream.codec_type === 'video'); const audioStream = metadata.streams.find(stream => stream.codec_type === 'audio'); if (!videoStream) { console.log('No video stream found in the file'); return null; } resolve({ duration: parseFloat(metadata.format.duration), width: videoStream.width, height: videoStream.height, fps: eval(videoStream.avg_frame_rate) || 30, hasAudio: !!audioStream, bitrate: parseInt(metadata.format.bit_rate) || 0, format: metadata.format.format_name }); } catch (parseError) { console.log(`Failed to parse video metadata: ${parseError.message}`); return null; } }); }); } /** * Create GIF from video clip using FFmpeg * @param {string} inputPath - Input video path * @param {string} outputPath - Output GIF path * @param {number} startTime - Start time in seconds * @param {number} duration - Duration in seconds * @returns {Promise<void>} */ function createGif(inputPath, outputPath, startTime, duration) { return new Promise((resolve, reject) => { let command = ffmpeg(inputPath) .seekInput(startTime) .duration(duration) .videoFilters([ 'scale=480:-1:flags=lanczos', // Scale to 480px width, maintain aspect ratio 'fps=15' // Set frame rate to 15 fps ]) .format('gif') .output(outputPath) .outputOptions([ '-y' // Overwrite output file if it exists ]); // Add progress tracking command.on('end', () => { resolve(); }); command.on('error', (err) => { console.log('\n❌ FFmpeg error:', err.message); console.log(`FFmpeg conversion failed: ${err.message}`); return null; }); // Start the conversion command.run(); }); } /** * Create a high-quality GIF with palette optimization * @param {string} inputPath - Input video path * @param {string} outputPath - Output GIF path * @param {number} startTime - Start time in seconds * @param {number} duration - Duration in seconds * @returns {Promise<void>} */ function createHighQualityGif(inputPath, outputPath, startTime, duration) { return new Promise((resolve, reject) => { const tempPalette = path.join(path.dirname(outputPath), 'temp_palette.png'); // First pass: generate palette ffmpeg(inputPath) .seekInput(startTime) .duration(duration) .complexFilter([ 'fps=15,scale=480:-1:flags=lanczos,palettegen' ]) .output(tempPalette) .on('end', () => { // Second pass: create GIF with palette ffmpeg(inputPath) .seekInput(startTime) .duration(duration) .input(tempPalette) .complexFilter([ 'fps=15,scale=480:-1:flags=lanczos[v];[v][1:v]paletteuse' ]) .format('gif') .output(outputPath) .on('end', () => { // Cleanup temp palette file if (fs.existsSync(tempPalette)) { fs.unlinkSync(tempPalette); } resolve(); }) .on('error', (err) => { // Cleanup temp palette file on error if (fs.existsSync(tempPalette)) { fs.unlinkSync(tempPalette); } console.log(`High-quality GIF conversion failed: ${err.message}`); return null; }) .run(); }) .on('error', (err) => { console.log(`Palette generation failed: ${err.message}`); return null; }) .run(); }); } /** * Sanitize filename by removing invalid characters * @param {string} filename - Original filename * @returns {string} Sanitized filename */ function sanitizeFilename(filename) { return filename .replace(/[<>:"/\\|?*]/g, '') // Remove invalid characters .replace(/\s+/g, '_') // Replace spaces with underscores .substring(0, 50) // Limit length for GIF filename .trim(); } /** * Format bytes to human readable format * @param {number} bytes - Number of bytes * @returns {string} Formatted string */ function formatBytes(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } export { extractAndConvertToGif, getVideoMetadata, createGif, createHighQualityGif, sanitizeFilename };