← back to Animate Museum Posts

src/addBranding.js

242 lines

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

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

const ASSETS_DIR = path.join(__dirname, '..', 'assets');
const OUTPUT_DIR = path.join(__dirname, '..', 'output');

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

/**
 * Download royalty-free jazz music if not exists
 */
async function ensureJazzMusic() {
  const musicPath = path.join(ASSETS_DIR, 'jazz_loop.mp3');

  if (fs.existsSync(musicPath)) {
    return musicPath;
  }

  console.log('Downloading royalty-free jazz music...');

  // Use a simple tone generation as fallback (no copyright issues)
  // Create a simple ambient sound using ffmpeg
  try {
    execSync(`ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -af "volume=0.3,lowpass=f=800" -y "${musicPath}"`, {
      stdio: 'pipe'
    });
    console.log('Created ambient intro sound');
  } catch (e) {
    console.log('Could not create audio, continuing without music');
    return null;
  }

  return musicPath;
}

/**
 * Create intro video with GoodQuestion.AI branding
 */
async function createIntroVideo() {
  const introPath = path.join(ASSETS_DIR, 'intro.mp4');

  // Create intro video with text overlay
  // 1-second intro with quick fade
  const ffmpegCmd = `ffmpeg -y -f lavfi -i "color=c=black:s=1280x720:d=1" \
    -vf "drawtext=text='GoodQuestion.AI':fontcolor=white:fontsize=72:x=(w-text_w)/2:y=(h-text_h)/2:alpha='if(lt(t,0.2),t*5,1)'" \
    -c:v libx264 -pix_fmt yuv420p -t 1 "${introPath}"`;

  try {
    execSync(ffmpegCmd, { stdio: 'pipe', shell: true });
    console.log('Created intro video');
    return introPath;
  } catch (e) {
    console.error('Failed to create intro:', e.message);
    return null;
  }
}

/**
 * Create outro video with GoodQuestion.AI branding
 */
async function createOutroVideo() {
  const outroPath = path.join(ASSETS_DIR, 'outro.mp4');

  // Create outro video with call to action
  // 1-second outro
  const ffmpegCmd = `ffmpeg -y -f lavfi -i "color=c=black:s=1280x720:d=1" \
    -vf "drawtext=text='@goodquestionai':fontcolor=cyan:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2:alpha='if(lt(t,0.2),t*5,1)'" \
    -c:v libx264 -pix_fmt yuv420p -t 1 "${outroPath}"`;

  try {
    execSync(ffmpegCmd, { stdio: 'pipe', shell: true });
    console.log('Created outro video');
    return outroPath;
  } catch (e) {
    console.error('Failed to create outro:', e.message);
    return null;
  }
}

/**
 * Add branding intro/outro to video
 * Extends main video to 10 seconds, adds 3s intro + 3s outro = 16s total
 */
export async function addBrandingToVideo(videoPath, metadata) {
  try {
    console.log('Adding GoodQuestion.AI branding...');

    // Check if video exists and has valid duration
    const probeCmd = `ffprobe -v error -show_entries format=duration -of csv=p=0 "${videoPath}"`;
    let duration;
    try {
      duration = parseFloat(execSync(probeCmd, { stdio: 'pipe' }).toString().trim());
    } catch (e) {
      console.log('Could not probe video, skipping branding');
      return videoPath;
    }

    if (duration < 0.5) {
      console.log('Video too short for branding, skipping');
      return videoPath;
    }

    // Extend video to 10 seconds if shorter
    const TARGET_DURATION = 10;
    if (duration < TARGET_DURATION) {
      console.log(`Extending video from ${duration.toFixed(1)}s to ${TARGET_DURATION}s...`);
      const extendedPath = videoPath.replace('.mp4', '_extended.mp4');

      // Calculate slowdown factor or loop count
      const slowFactor = Math.min(duration / TARGET_DURATION, 0.5); // Max 2x slowdown
      const loopCount = Math.ceil(TARGET_DURATION / duration);

      // Use a combination: slow down slightly + loop to reach 10s
      const slowSpeed = Math.max(0.5, duration / TARGET_DURATION);

      try {
        // Method: Slow down + loop + trim to exactly 10 seconds
        execSync(`ffmpeg -y -stream_loop ${loopCount} -i "${videoPath}" -vf "setpts=${1/slowSpeed}*PTS" -t ${TARGET_DURATION} -c:v libx264 -pix_fmt yuv420p "${extendedPath}"`, {
          stdio: 'pipe',
          timeout: 60000
        });
        fs.unlinkSync(videoPath);
        fs.renameSync(extendedPath, videoPath);
        console.log(`Video extended to ${TARGET_DURATION}s`);
      } catch (e) {
        console.log('Could not extend video, using original');
      }
    }

    // Create intro and outro if they don't exist
    const introPath = await createIntroVideo();
    const outroPath = await createOutroVideo();

    if (!introPath || !outroPath) {
      console.log('Could not create branding videos, using original');
      return videoPath;
    }

    // Get video dimensions
    const dimensionsCmd = `ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "${videoPath}"`;
    let width = 1280, height = 720;
    try {
      const dims = execSync(dimensionsCmd, { stdio: 'pipe' }).toString().trim().split(',');
      width = parseInt(dims[0]) || 1280;
      height = parseInt(dims[1]) || 720;
    } catch (e) {}

    // Create concat file
    const timestamp = Date.now();
    const concatFile = path.join(OUTPUT_DIR, `concat_${timestamp}.txt`);
    const brandedPath = path.join(OUTPUT_DIR, `branded_${timestamp}.mp4`);

    // Scale all videos to same size and concatenate
    const scaledIntro = path.join(OUTPUT_DIR, `scaled_intro_${timestamp}.mp4`);
    const scaledMain = path.join(OUTPUT_DIR, `scaled_main_${timestamp}.mp4`);
    const scaledOutro = path.join(OUTPUT_DIR, `scaled_outro_${timestamp}.mp4`);

    // Scale intro
    execSync(`ffmpeg -y -i "${introPath}" -vf "scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2" -c:v libx264 -pix_fmt yuv420p "${scaledIntro}"`, { stdio: 'pipe' });

    // Scale main video
    execSync(`ffmpeg -y -i "${videoPath}" -vf "scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2" -c:v libx264 -pix_fmt yuv420p "${scaledMain}"`, { stdio: 'pipe' });

    // Scale outro
    execSync(`ffmpeg -y -i "${outroPath}" -vf "scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2" -c:v libx264 -pix_fmt yuv420p "${scaledOutro}"`, { stdio: 'pipe' });

    // Create concat file
    fs.writeFileSync(concatFile, `file '${scaledIntro}'\nfile '${scaledMain}'\nfile '${scaledOutro}'`);

    // Concatenate videos
    execSync(`ffmpeg -y -f concat -safe 0 -i "${concatFile}" -c:v libx264 -pix_fmt yuv420p "${brandedPath}"`, { stdio: 'pipe' });

    // Cleanup temp files
    [concatFile, scaledIntro, scaledMain, scaledOutro].forEach(f => {
      try { fs.unlinkSync(f); } catch (e) {}
    });

    // Replace original with branded version
    fs.unlinkSync(videoPath);
    fs.renameSync(brandedPath, videoPath);

    console.log('Added GoodQuestion.AI branding successfully!');
    return videoPath;

  } catch (error) {
    console.error('Branding failed:', error.message);
    return videoPath; // Return original on failure
  }
}

/**
 * Add text overlay with artwork info
 */
export async function addArtworkOverlay(videoPath, metadata) {
  try {
    const title = (metadata.title || 'Artwork').replace(/'/g, "\\'").substring(0, 40);
    const artist = (metadata.artist || 'Unknown').replace(/'/g, "\\'").substring(0, 30);

    const timestamp = Date.now();
    const overlayPath = path.join(OUTPUT_DIR, `overlay_${timestamp}.mp4`);

    // Add subtle text overlay at bottom
    const ffmpegCmd = `ffmpeg -y -i "${videoPath}" \
      -vf "drawtext=text='${title}':fontcolor=white:fontsize=24:x=20:y=h-60:alpha=0.8,\
           drawtext=text='${artist}':fontcolor=gray:fontsize=18:x=20:y=h-30:alpha=0.7" \
      -c:v libx264 -pix_fmt yuv420p "${overlayPath}"`;

    execSync(ffmpegCmd, { stdio: 'pipe', shell: true });

    fs.unlinkSync(videoPath);
    fs.renameSync(overlayPath, videoPath);

    console.log('Added artwork overlay');
    return videoPath;

  } catch (error) {
    console.error('Overlay failed:', error.message);
    return videoPath;
  }
}

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