← back to Animate Museum Posts
src/createAnimation.js
127 lines
import sharp from 'sharp';
import GIFEncoder from 'gif-encoder-2';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export async function createAnimatedGIF(imagePath, effect = 'zoom') {
try {
console.log(`Creating animated GIF with ${effect} effect...`);
const outputDir = path.join(__dirname, '..', 'output');
const timestamp = new Date().getTime();
const outputPath = path.join(outputDir, `animated_${timestamp}.gif`);
// Load the image
const image = sharp(imagePath);
const metadata = await image.metadata();
const width = 500; // Smaller size for Twitter
const height = 500;
const frameCount = 15; // Fewer frames for smaller file
// Create GIF encoder
const encoder = new GIFEncoder(width, height, 'neuquant');
encoder.setDelay(150); // 150ms between frames for smoother playback
encoder.setRepeat(0); // Loop forever
encoder.setQuality(20); // Lower quality for smaller file size
const stream = fs.createWriteStream(outputPath);
encoder.createReadStream().pipe(stream);
encoder.start();
// Generate frames based on effect
for (let i = 0; i < frameCount; i++) {
const progress = i / frameCount;
let frameBuffer;
switch (effect) {
case 'zoom':
// Ken Burns zoom effect
const zoomFactor = 1 + (progress * 0.3); // Zoom from 100% to 130%
const cropSize = Math.round(Math.min(metadata.width, metadata.height) / zoomFactor);
const xOffset = Math.round((metadata.width - cropSize) * progress / 2);
const yOffset = Math.round((metadata.height - cropSize) * progress / 2);
frameBuffer = await sharp(imagePath)
.extract({
left: xOffset,
top: yOffset,
width: Math.min(cropSize, metadata.width - xOffset),
height: Math.min(cropSize, metadata.height - yOffset)
})
.resize(width, height, { fit: 'cover' })
.raw()
.toBuffer();
break;
case 'pan':
// Horizontal pan effect
const panOffset = Math.round((metadata.width - width) * progress);
frameBuffer = await sharp(imagePath)
.extract({
left: Math.min(panOffset, metadata.width - width),
top: 0,
width: width,
height: Math.min(height, metadata.height)
})
.resize(width, height, { fit: 'cover' })
.raw()
.toBuffer();
break;
case 'fade':
// Fade in/out effect
const opacity = progress < 0.5
? progress * 2
: 2 - (progress * 2);
frameBuffer = await sharp(imagePath)
.resize(width, height, { fit: 'cover' })
.modulate({ brightness: 0.3 + (opacity * 0.7) })
.raw()
.toBuffer();
break;
default:
// Static frame
frameBuffer = await sharp(imagePath)
.resize(width, height, { fit: 'cover' })
.raw()
.toBuffer();
}
// Convert raw buffer to RGBA for GIF encoder
const rgbaBuffer = Buffer.alloc(width * height * 4);
for (let j = 0; j < frameBuffer.length; j += 3) {
const pixelIndex = Math.floor(j / 3) * 4;
rgbaBuffer[pixelIndex] = frameBuffer[j]; // R
rgbaBuffer[pixelIndex + 1] = frameBuffer[j + 1]; // G
rgbaBuffer[pixelIndex + 2] = frameBuffer[j + 2]; // B
rgbaBuffer[pixelIndex + 3] = 255; // A
}
encoder.addFrame(rgbaBuffer);
}
// Skip reverse frames to keep file size small for Twitter
encoder.finish();
return new Promise((resolve, reject) => {
stream.on('finish', () => {
console.log(`Animated GIF created: ${outputPath}`);
resolve(outputPath);
});
stream.on('error', reject);
});
} catch (error) {
console.error('Error creating animated GIF:', error);
throw error;
}
}