← back to Goodquestion Ai

scripts/generate-gemini-thumbnails.cjs

364 lines

/**
 * Gemini AI YouTube Thumbnail Generator
 * Uses the Gemini 2.0 Flash image generation model to create
 * 5 professional YouTube thumbnail images at 1280x720.
 *
 * Model: gemini-2.0-flash-exp-image-generation
 * API: https://generativelanguage.googleapis.com/v1beta/
 *
 * Usage:
 *   node generate-gemini-thumbnails.cjs
 *   node generate-gemini-thumbnails.cjs --name parallel-agents
 *
 * @module generate-gemini-thumbnails
 */

'use strict';

const https = require('https');
const fs = require('fs');
const path = require('path');

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const GEMINI_API_KEY = process.env.GEMINI_IMAGE_API_KEY;
const MODEL = 'gemini-2.0-flash-exp-image-generation';
const GEMINI_HOST = 'generativelanguage.googleapis.com';
const GEMINI_PATH = `/v1beta/models/${MODEL}:generateContent?key=${GEMINI_API_KEY}`;

const OUTDIR = path.join(__dirname, '..', 'dashboard-captures');

/** Maximum retry attempts per thumbnail */
const MAX_RETRIES = 3;
/** Delay between retries in milliseconds (exponential back-off base) */
const RETRY_BASE_DELAY_MS = 5000;
/** Per-request timeout in milliseconds */
const REQUEST_TIMEOUT_MS = 120_000;

// ---------------------------------------------------------------------------
// Thumbnail prompt definitions
// ---------------------------------------------------------------------------

/**
 * @typedef {Object} ThumbnailDef
 * @property {string} name  - Output filename stem (gemini-thumb-{name}.png)
 * @property {string} prompt - Prompt sent to Gemini image generation
 */

/** @type {ThumbnailDef[]} */
const THUMBNAILS = [
  {
    name: 'parallel-agents',
    prompt:
      'A dark futuristic tech thumbnail for YouTube. Shows 6 glowing AI agent nodes connected by neon orange lines on a dark navy background. Large bold white text "PARALLEL AGENTS" with smaller orange text "AT SCALE". Terminal/code aesthetic. Professional, clean, dark theme. 1280x720.',
  },
  {
    name: 'dashboard-walkthrough',
    prompt:
      'A dark futuristic tech thumbnail for YouTube. Shows a glowing command center dashboard hologram with data visualizations in teal/cyan. Large bold white text "COMMAND CENTER" with smaller teal text "DASHBOARD TOUR". 95 vendor nodes visible. Professional, dark theme. 1280x720.',
  },
  {
    name: 'remote-control',
    prompt:
      'A dark futuristic tech thumbnail for YouTube. Shows a hand controlling multiple floating AI processes/containers glowing purple. Large bold white text "AUTONOMOUS AGENTS" with smaller purple text "105 PROCESSES". Dark background with grid pattern. 1280x720.',
  },
  {
    name: 'blog-tour',
    prompt:
      'A dark futuristic tech thumbnail for YouTube. Shows a blog website emerging from code/terminal with golden glowing text. Large bold white text "AI BUILDS ITS OWN BLOG" with smaller gold text. Astro.js logo aesthetic. Dark theme. 1280x720.',
  },
  {
    name: 'tracking-progress',
    prompt:
      'A dark futuristic tech thumbnail for YouTube. Shows an upward trending chart/graph going from 94K to 118K with glowing cyan data points. Large bold white text "DATA PIPELINE" with smaller cyan text "FROM ZERO TO 118K". Dark navy background. 1280x720.',
  },
];

// ---------------------------------------------------------------------------
// HTTP helper — wraps https.request as a Promise
// ---------------------------------------------------------------------------

/**
 * Sends a JSON POST request via Node's built-in https module.
 *
 * @param {string} host
 * @param {string} urlPath
 * @param {object} body - Request payload (will be JSON-serialised)
 * @param {number} [timeoutMs=REQUEST_TIMEOUT_MS]
 * @returns {Promise<object>} Parsed JSON response body
 */
function httpsPost(host, urlPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
  return new Promise((resolve, reject) => {
    const payload = JSON.stringify(body);

    const options = {
      hostname: host,
      path: urlPath,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(payload),
      },
    };

    const req = https.request(options, (res) => {
      const chunks = [];
      res.on('data', (chunk) => chunks.push(chunk));
      res.on('end', () => {
        const raw = Buffer.concat(chunks).toString('utf8');
        let parsed;
        try {
          parsed = JSON.parse(raw);
        } catch (e) {
          return reject(new Error(`Failed to parse JSON response: ${raw.slice(0, 200)}`));
        }
        if (res.statusCode >= 400) {
          const errMsg = parsed?.error?.message || JSON.stringify(parsed).slice(0, 300);
          return reject(new Error(`HTTP ${res.statusCode}: ${errMsg}`));
        }
        resolve(parsed);
      });
    });

    req.setTimeout(timeoutMs, () => {
      req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
    });

    req.on('error', reject);
    req.write(payload);
    req.end();
  });
}

// ---------------------------------------------------------------------------
// Core: call Gemini and extract image bytes
// ---------------------------------------------------------------------------

/**
 * Calls the Gemini image generation API for a single prompt.
 *
 * @param {string} prompt
 * @returns {Promise<Buffer>} PNG/JPEG image data as a Buffer
 */
async function generateImage(prompt) {
  const requestBody = {
    contents: [
      {
        parts: [{ text: prompt }],
      },
    ],
    generationConfig: {
      responseModalities: ['TEXT', 'IMAGE'],
    },
  };

  const response = await httpsPost(GEMINI_HOST, GEMINI_PATH, requestBody);

  // Walk the response parts looking for image data
  const parts = response?.candidates?.[0]?.content?.parts ?? [];
  for (const part of parts) {
    if (part?.inlineData?.mimeType?.startsWith('image/')) {
      const base64Data = part.inlineData.data;
      if (!base64Data) {
        throw new Error('Image part found but data field is empty');
      }
      return Buffer.from(base64Data, 'base64');
    }
  }

  // No image found — log the raw response structure for debugging
  const textParts = parts
    .filter((p) => p?.text)
    .map((p) => p.text)
    .join(' ');
  throw new Error(
    `No image data in response. Text parts: "${textParts.slice(0, 300)}" | ` +
    `Finish reason: ${response?.candidates?.[0]?.finishReason ?? 'unknown'}`
  );
}

// ---------------------------------------------------------------------------
// Retry wrapper with exponential back-off
// ---------------------------------------------------------------------------

/**
 * Calls an async function with retry logic and exponential back-off.
 *
 * @template T
 * @param {() => Promise<T>} fn - Async function to retry
 * @param {number} maxRetries
 * @param {number} baseDelayMs
 * @param {string} label - Human-readable label for log output
 * @returns {Promise<T>}
 */
async function withRetry(fn, maxRetries, baseDelayMs, label) {
  let lastError;
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;
      if (attempt < maxRetries) {
        const delay = baseDelayMs * Math.pow(2, attempt - 1);
        console.warn(
          `  [${label}] Attempt ${attempt}/${maxRetries} failed: ${err.message}. ` +
          `Retrying in ${delay / 1000}s...`
        );
        await sleep(delay);
      }
    }
  }
  throw lastError;
}

/**
 * Returns a promise that resolves after the given number of milliseconds.
 * @param {number} ms
 * @returns {Promise<void>}
 */
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// ---------------------------------------------------------------------------
// Resize using sharp (optional, but ensures exact 1280x720 output)
// ---------------------------------------------------------------------------

/**
 * Optionally resizes the image buffer to 1280x720 using sharp.
 * Falls back to writing the raw buffer if sharp is unavailable.
 *
 * @param {Buffer} imageBuffer
 * @param {string} outPath
 * @returns {Promise<void>}
 */
async function saveImage(imageBuffer, outPath) {
  let sharp;
  try {
    sharp = require('/root/Projects/goodquestion-ai/node_modules/sharp');
  } catch (_) {
    // sharp not available — write raw bytes
  }

  if (sharp) {
    await sharp(imageBuffer)
      .resize(1280, 720, { fit: 'cover', position: 'center' })
      .png({ quality: 95 })
      .toFile(outPath);
  } else {
    fs.writeFileSync(outPath, imageBuffer);
  }
}

// ---------------------------------------------------------------------------
// Process a single thumbnail definition
// ---------------------------------------------------------------------------

/**
 * Generates and saves one thumbnail image.
 *
 * @param {ThumbnailDef} def
 * @returns {Promise<{name: string, outPath: string, success: boolean, error?: string}>}
 */
async function processThumbnail(def) {
  const outPath = path.join(OUTDIR, `gemini-thumb-${def.name}.png`);
  console.log(`\n[${def.name}] Starting generation...`);
  console.log(`  Prompt: ${def.prompt.slice(0, 80)}...`);

  try {
    const imageBuffer = await withRetry(
      () => generateImage(def.prompt),
      MAX_RETRIES,
      RETRY_BASE_DELAY_MS,
      def.name
    );

    await saveImage(imageBuffer, outPath);

    const fileSizeKb = (fs.statSync(outPath).size / 1024).toFixed(0);
    console.log(`  [${def.name}] Saved: ${outPath} (${fileSizeKb} KB)`);
    return { name: def.name, outPath, success: true };
  } catch (err) {
    console.error(`  [${def.name}] FAILED after ${MAX_RETRIES} attempts: ${err.message}`);
    return { name: def.name, outPath, success: false, error: err.message };
  }
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

/**
 * Main function — parses CLI args, ensures output directory exists,
 * and runs thumbnail generation sequentially to avoid rate limits.
 *
 * @returns {Promise<void>}
 */
async function main() {
  console.log('=== Gemini Thumbnail Generator ===');
  console.log(`Model: ${MODEL}`);
  console.log(`Output directory: ${OUTDIR}\n`);

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

  // Parse optional --name filter
  const args = process.argv.slice(2);
  const nameIdx = args.indexOf('--name');
  const filterName = nameIdx !== -1 ? args[nameIdx + 1] : null;

  const targets = filterName
    ? THUMBNAILS.filter((t) => t.name === filterName)
    : THUMBNAILS;

  if (filterName && targets.length === 0) {
    console.error(`Error: No thumbnail defined with name "${filterName}"`);
    console.error(`Valid names: ${THUMBNAILS.map((t) => t.name).join(', ')}`);
    process.exit(1);
  }

  // Run sequentially — Gemini image gen can be slow, and parallel requests
  // risk hitting quota limits
  const results = [];
  for (const def of targets) {
    const result = await processThumbnail(def);
    results.push(result);
    // Brief pause between requests to stay within rate limits
    if (targets.indexOf(def) < targets.length - 1) {
      console.log('  Pausing 3s before next request...');
      await sleep(3000);
    }
  }

  // Summary
  console.log('\n=== Summary ===');
  const succeeded = results.filter((r) => r.success);
  const failed = results.filter((r) => !r.success);

  if (succeeded.length > 0) {
    console.log(`\nSucceeded (${succeeded.length}):`);
    succeeded.forEach((r) => console.log(`  gemini-thumb-${r.name}.png`));
  }

  if (failed.length > 0) {
    console.log(`\nFailed (${failed.length}):`);
    failed.forEach((r) => console.log(`  ${r.name}: ${r.error}`));
  }

  console.log(`\nTotal: ${succeeded.length}/${results.length} thumbnails generated successfully.`);

  if (failed.length > 0) {
    process.exit(1);
  }
}

main().catch((err) => {
  console.error('Unexpected error:', err);
  process.exit(1);
});