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