← back to Animate Museum Posts
src/animateKenBurns.js
131 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);
/**
* Create Ken Burns effect (pan/zoom) animation locally
* No external API required - uses sharp for image manipulation
*/
export async function createKenBurnsAnimation(imagePath, metadata) {
try {
console.log('Creating Ken Burns animation locally...');
const FRAME_COUNT = 30;
const FPS = 6;
const DURATION = 5; // 5 seconds
// Get image dimensions
const imageInfo = await sharp(imagePath).metadata();
const { width, height } = imageInfo;
// Target output size
const outputWidth = 800;
const outputHeight = 600;
// Create GIF encoder
const encoder = new GIFEncoder(outputWidth, outputHeight, 'neuquant', true);
const outputDir = path.join(__dirname, '..', 'output');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const timestamp = Date.now();
const gifPath = path.join(outputDir, `kenburns_${timestamp}.gif`);
const writeStream = fs.createWriteStream(gifPath);
encoder.createReadStream().pipe(writeStream);
encoder.start();
encoder.setDelay(1000 / FPS);
encoder.setQuality(10);
encoder.setRepeat(0); // Loop forever
// Ken Burns parameters - slow zoom in with slight pan
const startScale = 1.0;
const endScale = 1.15; // 15% zoom
const panX = width * 0.05; // 5% horizontal pan
const panY = height * 0.03; // 3% vertical pan
console.log(`Generating ${FRAME_COUNT} frames...`);
for (let i = 0; i < FRAME_COUNT; i++) {
const progress = i / (FRAME_COUNT - 1);
// Calculate current scale and position
const scale = startScale + (endScale - startScale) * progress;
const cropWidth = Math.floor(width / scale);
const cropHeight = Math.floor(height / scale);
// Center with slight pan
const left = Math.floor((width - cropWidth) / 2 + panX * progress);
const top = Math.floor((height - cropHeight) / 2 + panY * progress);
// Ensure we don't go out of bounds
const safeLeft = Math.max(0, Math.min(left, width - cropWidth));
const safeTop = Math.max(0, Math.min(top, height - cropHeight));
// Extract and resize frame
const frameBuffer = await sharp(imagePath)
.extract({
left: safeLeft,
top: safeTop,
width: cropWidth,
height: cropHeight
})
.resize(outputWidth, outputHeight)
.raw()
.toBuffer();
// Add frame to GIF
encoder.addFrame(frameBuffer);
}
encoder.finish();
// Wait for write to complete
await new Promise((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
const stats = fs.statSync(gifPath);
console.log(`Ken Burns GIF created: ${(stats.size / 1024).toFixed(1)} KB`);
return {
originalImage: imagePath,
animatedImage: gifPath,
artworkMetadata: metadata,
isAnimated: true,
animationType: 'ken-burns',
format: 'gif',
frames: FRAME_COUNT,
duration: DURATION
};
} catch (error) {
console.error('Ken Burns animation failed:', error.message);
return {
originalImage: imagePath,
isStatic: true,
artworkMetadata: metadata,
error: error.message
};
}
}
// Test if run directly
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const testImage = process.argv[2] || path.join(__dirname, '..', 'output', 'artwork_test.jpg');
if (fs.existsSync(testImage)) {
createKenBurnsAnimation(testImage, { title: 'Test' })
.then(r => console.log('Result:', r))
.catch(e => console.error('Failed:', e));
} else {
console.log('No test image found. Usage: node animateKenBurns.js <image-path>');
}
}