← back to Animate Museum Posts

src/verifyVideo.js

153 lines

import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const VERIFICATION_DIR = path.join(__dirname, '..', 'verification');

// Ensure verification directory exists
if (!fs.existsSync(VERIFICATION_DIR)) fs.mkdirSync(VERIFICATION_DIR, { recursive: true });

/**
 * Verify video has actual motion/animation
 * Returns motion score and verification result
 */
export async function verifyVideoMotion(videoPath) {
  console.log(`Verifying video motion: ${path.basename(videoPath)}`);

  if (!fs.existsSync(videoPath)) {
    return { verified: false, error: 'Video file not found', motionScore: 0 };
  }

  const timestamp = Date.now();
  const framesDir = path.join(VERIFICATION_DIR, `frames_${timestamp}`);
  fs.mkdirSync(framesDir, { recursive: true });

  try {
    // Extract frames at different timestamps
    const frameCount = 5;
    for (let i = 0; i < frameCount; i++) {
      const time = (i + 1) * 0.5; // Extract at 0.5s, 1s, 1.5s, 2s, 2.5s
      execSync(`ffmpeg -y -ss ${time} -i "${videoPath}" -vframes 1 "${framesDir}/frame_${i}.jpg" 2>/dev/null`, { stdio: 'pipe' });
    }

    // Compare consecutive frames using ImageMagick
    let totalDiff = 0;
    let comparisons = 0;

    for (let i = 0; i < frameCount - 1; i++) {
      const frame1 = `${framesDir}/frame_${i}.jpg`;
      const frame2 = `${framesDir}/frame_${i + 1}.jpg`;

      if (fs.existsSync(frame1) && fs.existsSync(frame2)) {
        try {
          // Get difference percentage using ImageMagick compare
          const result = execSync(`compare -metric RMSE "${frame1}" "${frame2}" null: 2>&1 || true`, { encoding: 'utf8' });
          const match = result.match(/\(([\d.]+)\)/);
          if (match) {
            totalDiff += parseFloat(match[1]) * 100;
            comparisons++;
          }
        } catch (e) {
          // ImageMagick returns non-zero exit on diff, parse output anyway
          const match = e.stdout?.toString().match(/\(([\d.]+)\)/) || e.message.match(/\(([\d.]+)\)/);
          if (match) {
            totalDiff += parseFloat(match[1]) * 100;
            comparisons++;
          }
        }
      }
    }

    // Cleanup frames
    fs.rmSync(framesDir, { recursive: true, force: true });

    const avgDiff = comparisons > 0 ? totalDiff / comparisons : 0;

    // Motion thresholds:
    // < 1%: No motion (static image)
    // 1-5%: Subtle motion (panning/zoom only)
    // 5-15%: Moderate motion (good animation)
    // > 15%: High motion (morphing/dramatic changes)

    const verified = avgDiff > 3; // At least 3% difference = animation present
    const motionLevel = avgDiff < 1 ? 'none' : avgDiff < 5 ? 'subtle' : avgDiff < 15 ? 'moderate' : 'high';

    console.log(`  Motion score: ${avgDiff.toFixed(2)}% (${motionLevel})`);

    return {
      verified,
      motionScore: avgDiff,
      motionLevel,
      message: verified
        ? `Video has ${motionLevel} motion (${avgDiff.toFixed(1)}% frame difference)`
        : `Video appears static or has minimal motion (${avgDiff.toFixed(1)}% frame difference)`
    };

  } catch (error) {
    // Cleanup on error
    try { fs.rmSync(framesDir, { recursive: true, force: true }); } catch (e) {}

    console.error(`  Verification error: ${error.message}`);
    return { verified: false, error: error.message, motionScore: 0 };
  }
}

/**
 * Verify video duration is within expected range
 */
export function verifyVideoDuration(videoPath, minSeconds = 8, maxSeconds = 20) {
  try {
    const result = execSync(`ffprobe -v error -show_entries format=duration -of csv=p=0 "${videoPath}"`, { encoding: 'utf8' });
    const duration = parseFloat(result.trim());

    const verified = duration >= minSeconds && duration <= maxSeconds;

    return {
      verified,
      duration,
      message: verified
        ? `Duration OK: ${duration.toFixed(1)}s`
        : `Duration out of range: ${duration.toFixed(1)}s (expected ${minSeconds}-${maxSeconds}s)`
    };
  } catch (error) {
    return { verified: false, duration: 0, error: error.message };
  }
}

/**
 * Full video verification
 */
export async function verifyVideo(videoPath) {
  console.log(`\n=== Verifying: ${path.basename(videoPath)} ===`);

  const motionResult = await verifyVideoMotion(videoPath);
  const durationResult = verifyVideoDuration(videoPath);

  const allVerified = motionResult.verified && durationResult.verified;

  console.log(`  Motion: ${motionResult.message}`);
  console.log(`  Duration: ${durationResult.message}`);
  console.log(`  Overall: ${allVerified ? 'VERIFIED' : 'FAILED'}`);

  return {
    verified: allVerified,
    motion: motionResult,
    duration: durationResult
  };
}

// Test if run directly
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const testVideo = process.argv[2];
  if (testVideo) {
    verifyVideo(testVideo)
      .then(r => console.log('\nResult:', JSON.stringify(r, null, 2)))
      .catch(e => console.error('Error:', e));
  } else {
    console.log('Usage: node verifyVideo.js <video-path>');
  }
}