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.68 kB
JavaScript
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) {
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 = 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
};