← 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>');
  }
}