← back to Animate Museum Posts

src/animateWithGrokFrames.js

206 lines

import axios from 'axios';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import GIFEncoder from 'gif-encoder-2';
import sharp from 'sharp';

dotenv.config();

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const GROK_API_KEY = process.env.GROK_API_KEY;
const GROK_API_URL = 'https://api.x.ai/v1';

export async function createAIAnimation(imagePath, metadata) {
  try {
    if (!GROK_API_KEY) {
      throw new Error('GROK_API_KEY not configured');
    }

    console.log('Creating AI animation using Grok vision capabilities...');

    const imageBuffer = fs.readFileSync(imagePath);
    const base64Image = imageBuffer.toString('base64');

    // Ask Grok to describe how the painting should be animated
    const animationResponse = await axios.post(
      `${GROK_API_URL}/chat/completions`,
      {
        model: 'grok-2-vision-1212',
        messages: [
          {
            role: 'system',
            content: 'You are an expert at bringing classical paintings to life through animation.'
          },
          {
            role: 'user',
            content: [
              {
                type: 'text',
                text: `Analyze this painting "${metadata.title}" and describe exactly how to animate it. Focus on one subtle movement that would bring it to life. For example: water rippling, flames flickering, fabric swaying, eyes blinking, or light changing. Be specific about what moves and how. Keep it under 50 words.`
              },
              {
                type: 'image_url',
                image_url: {
                  url: `data:image/jpeg;base64,${base64Image}`
                }
              }
            ]
          }
        ],
        max_tokens: 100,
        temperature: 0.7
      },
      {
        headers: {
          'Authorization': `Bearer ${GROK_API_KEY}`,
          'Content-Type': 'application/json'
        }
      }
    );

    const animationConcept = animationResponse.data.choices[0].message.content;
    console.log('Animation concept:', animationConcept);

    // Since Grok can't generate actual video, we'll create a sophisticated animated GIF
    // with effects that simulate the suggested animation
    const outputDir = path.join(__dirname, '..', 'output');
    const timestamp = new Date().getTime();
    const outputPath = path.join(outputDir, `ai_animated_${timestamp}.gif`);

    const width = 500;
    const height = 500;
    const frameCount = 20;

    // Load and prepare the base image
    const baseImage = await sharp(imagePath)
      .resize(width, height, { fit: 'cover' })
      .jpeg()
      .toBuffer();

    // Create GIF encoder
    const encoder = new GIFEncoder(width, height, 'neuquant');
    encoder.setDelay(100); // 100ms between frames
    encoder.setRepeat(0); // Loop forever
    encoder.setQuality(10);

    const stream = fs.createWriteStream(outputPath);
    encoder.createReadStream().pipe(stream);
    encoder.start();

    // Generate frames with subtle animations based on the concept
    for (let i = 0; i < frameCount; i++) {
      const progress = i / frameCount;

      // Create frame with effects based on animation concept
      let frameBuffer;

      if (animationConcept.toLowerCase().includes('water') || animationConcept.toLowerCase().includes('rippl')) {
        // Water ripple effect
        const wave = Math.sin(progress * Math.PI * 4) * 5;
        frameBuffer = await sharp(baseImage)
          .modulate({
            brightness: 1 + (Math.sin(progress * Math.PI * 2) * 0.05),
            saturation: 1 + (Math.cos(progress * Math.PI * 2) * 0.1)
          })
          .blur(Math.max(0.3, Math.abs(wave) * 0.3))
          .raw()
          .toBuffer();

      } else if (animationConcept.toLowerCase().includes('flame') || animationConcept.toLowerCase().includes('fire')) {
        // Fire flicker effect
        frameBuffer = await sharp(baseImage)
          .modulate({
            brightness: 0.9 + (Math.random() * 0.2),
            hue: Math.floor(Math.random() * 10 - 5)
          })
          .raw()
          .toBuffer();

      } else if (animationConcept.toLowerCase().includes('light') || animationConcept.toLowerCase().includes('glow')) {
        // Lighting change effect
        const lightIntensity = 0.8 + (Math.sin(progress * Math.PI * 2) * 0.3);
        frameBuffer = await sharp(baseImage)
          .modulate({
            brightness: lightIntensity
          })
          .gamma(Math.max(1.0, Math.min(3.0, 1.0 + (Math.sin(progress * Math.PI * 2) * 0.2))))
          .raw()
          .toBuffer();

      } else if (animationConcept.toLowerCase().includes('wind') || animationConcept.toLowerCase().includes('sway')) {
        // Swaying effect with slight distortion
        const skewAmount = Math.sin(progress * Math.PI * 2) * 2;
        frameBuffer = await sharp(baseImage)
          .rotate(skewAmount, { background: { r: 0, g: 0, b: 0, alpha: 0 } })
          .resize(width, height, { fit: 'cover' })
          .raw()
          .toBuffer();

      } else {
        // Default: subtle zoom and brightness pulse
        const zoomFactor = 1 + (Math.sin(progress * Math.PI * 2) * 0.02);
        const size = Math.round(width * zoomFactor);

        frameBuffer = await sharp(baseImage)
          .resize(size, size, { fit: 'cover' })
          .extract({
            left: Math.round((size - width) / 2),
            top: Math.round((size - height) / 2),
            width: width,
            height: height
          })
          .modulate({
            brightness: 0.95 + (Math.sin(progress * Math.PI * 2) * 0.05)
          })
          .raw()
          .toBuffer();
      }

      // Convert 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);
    }

    encoder.finish();

    return new Promise((resolve) => {
      stream.on('finish', () => {
        console.log(`AI-animated GIF created: ${outputPath}`);

        // Check file size
        const stats = fs.statSync(outputPath);
        console.log(`File size: ${(stats.size / 1024).toFixed(2)} KB`);

        resolve({
          originalImage: imagePath,
          animatedImage: outputPath,
          artworkMetadata: metadata,
          isAnimated: true,
          animationType: 'ai-enhanced',
          animationConcept: animationConcept
        });
      });
    });

  } catch (error) {
    console.error('Error creating AI animation:', error.message);
    return {
      originalImage: imagePath,
      isStatic: true,
      artworkMetadata: metadata,
      error: error.message
    };
  }
}