← back to SmokeShop

lib/image-generator.js

212 lines

/**
 * Image Generator — Gemini AI-powered Instagram post graphics
 * Primary: Gemini image generation for AI-created branded posts
 * Fallback: Canvas-based programmatic generation
 */

const { createCanvas, registerFont, loadImage } = require('canvas');
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
const gemini = require('./gemini');

// Register system fonts (for canvas fallback)
try {
  registerFont('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', { family: 'DejaVu Sans', weight: 'bold' });
  registerFont('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', { family: 'DejaVu Sans' });
} catch (e) {
  console.log('[ImageGen] Using default fonts');
}

const SIZE = 1080;
const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
const GENERATED_DIR = path.join(__dirname, '..', 'generated');

if (!fs.existsSync(GENERATED_DIR)) fs.mkdirSync(GENERATED_DIR, { recursive: true });

function loadTemplate(templateId) {
  const configPath = path.join(TEMPLATES_DIR, `template-${templateId}`, 'config.json');
  if (!fs.existsSync(configPath)) throw new Error(`Template ${templateId} not found`);
  return JSON.parse(fs.readFileSync(configPath, 'utf8'));
}

/**
 * Generate post using Gemini AI (primary method)
 */
async function generateWithGemini(options) {
  const { title, category, template, postId } = options;

  console.log(`[ImageGen] Gemini generating: "${title}" (${category}, template ${template})`);

  const result = await gemini.generateInstagramImage({
    title,
    category,
    template,
    storeName: 'Smoke & Vape Depot',
    storeAddress: '6100 Reseda Blvd, Reseda CA',
  });

  // Resize to exact 1080x1080 with sharp
  const resized = await sharp(result.imageBuffer)
    .resize(SIZE, SIZE, { fit: 'cover' })
    .png()
    .toBuffer();

  const filename = postId ? `post-${postId}.png` : `post-${Date.now()}.png`;
  const outputPath = path.join(GENERATED_DIR, filename);
  fs.writeFileSync(outputPath, resized);

  console.log(`[ImageGen] Gemini image saved: ${filename}`);
  return { path: outputPath, filename, buffer: resized };
}

/**
 * Generate post using canvas (fallback method)
 */
async function generateWithCanvas(options) {
  const { title, backgroundImage, template = 'a', category = 'product', postId } = options;
  const config = loadTemplate(template);

  const canvas = createCanvas(SIZE, SIZE);
  const ctx = canvas.getContext('2d');

  // Background
  if (backgroundImage && fs.existsSync(backgroundImage)) {
    const img = await loadImage(backgroundImage);
    const scale = Math.max(SIZE / img.width, SIZE / img.height);
    const w = img.width * scale;
    const h = img.height * scale;
    ctx.drawImage(img, (SIZE - w) / 2, (SIZE - h) / 2, w, h);
    ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
    ctx.fillRect(0, 0, SIZE, SIZE);
  } else {
    const { colors, direction } = config.background;
    const gradient = direction === 'diagonal'
      ? ctx.createLinearGradient(0, 0, SIZE, SIZE)
      : ctx.createLinearGradient(0, 0, 0, SIZE);
    gradient.addColorStop(0, colors[0]);
    gradient.addColorStop(1, colors[1]);
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, SIZE, SIZE);
  }

  // Subtle pattern overlay
  ctx.globalAlpha = 0.05;
  ctx.strokeStyle = '#ffffff';
  ctx.lineWidth = 1;
  for (let i = 0; i < SIZE; i += 30) {
    ctx.beginPath();
    ctx.moveTo(0, i);
    ctx.lineTo(SIZE, i);
    ctx.stroke();
  }
  ctx.globalAlpha = 1;

  const border = config.border;

  // Outer border
  ctx.strokeStyle = border.color;
  ctx.lineWidth = border.width;
  ctx.strokeRect(border.width / 2, border.width / 2, SIZE - border.width, SIZE - border.width);

  if (border.style === 'double') {
    ctx.strokeStyle = border.innerColor;
    ctx.lineWidth = 3;
    const inset = border.width + 8;
    ctx.strokeRect(inset, inset, SIZE - inset * 2, SIZE - inset * 2);
  }

  // Category accent bar
  const catColor = config.categoryColors[category] || config.accent;
  ctx.fillStyle = catColor;
  ctx.fillRect(border.width, border.width, SIZE - border.width * 2, 6);

  // Category label
  ctx.font = `bold 16px "${config.text.fontFamily}"`;
  ctx.fillStyle = catColor;
  const catLabel = category.toUpperCase();
  const catMetrics = ctx.measureText(catLabel);
  ctx.fillText(catLabel, SIZE - border.width - catMetrics.width - 20, border.width + 40);

  // Main title
  ctx.font = `bold ${config.text.fontSize}px "${config.text.fontFamily}"`;
  ctx.fillStyle = config.text.color;
  ctx.shadowColor = config.text.shadowColor;
  ctx.shadowBlur = config.text.shadowBlur;
  ctx.shadowOffsetX = 0;
  ctx.shadowOffsetY = 4;

  const maxTextWidth = SIZE - border.width * 2 - 120;
  const words = title.split(' ');
  const lines = [];
  let currentLine = '';
  for (const word of words) {
    const testLine = currentLine + (currentLine ? ' ' : '') + word;
    if (ctx.measureText(testLine).width > maxTextWidth && currentLine) {
      lines.push(currentLine);
      currentLine = word;
    } else {
      currentLine = testLine;
    }
  }
  if (currentLine) lines.push(currentLine);

  const lineHeight = config.text.fontSize * 1.2;
  const totalHeight = lines.length * lineHeight;
  const startY = (SIZE / 2) - (totalHeight / 2) + config.text.fontSize * 0.3;
  lines.forEach((line, i) => {
    const metrics = ctx.measureText(line);
    ctx.fillText(line, (SIZE - metrics.width) / 2, startY + i * lineHeight);
  });

  ctx.shadowColor = 'transparent';
  ctx.shadowBlur = 0;
  ctx.shadowOffsetY = 0;

  // Store name
  ctx.font = `bold ${config.storeName.fontSize}px "${config.text.fontFamily}"`;
  ctx.fillStyle = config.storeName.color;
  const storeMetrics = ctx.measureText(config.storeName.text);
  ctx.fillText(config.storeName.text, (SIZE - storeMetrics.width) / 2, SIZE - border.width - 70);

  // Address
  ctx.font = `${config.address.fontSize}px "${config.text.fontFamily}"`;
  ctx.fillStyle = config.address.color;
  const addrMetrics = ctx.measureText(config.address.text);
  ctx.fillText(config.address.text, (SIZE - addrMetrics.width) / 2, SIZE - border.width - 42);

  // Age disclaimer
  ctx.font = `bold ${config.ageDisclaimer.fontSize}px "${config.text.fontFamily}"`;
  ctx.fillStyle = config.ageDisclaimer.color;
  const ageMetrics = ctx.measureText(config.ageDisclaimer.text);
  ctx.fillText(config.ageDisclaimer.text, (SIZE - ageMetrics.width) / 2, SIZE - border.width - 18);

  const buffer = canvas.toBuffer('image/png');
  const filename = postId ? `post-${postId}.png` : `post-${Date.now()}.png`;
  const outputPath = path.join(GENERATED_DIR, filename);
  fs.writeFileSync(outputPath, buffer);

  return { path: outputPath, filename, buffer };
}

/**
 * Generate a branded Instagram post graphic
 * Tries Gemini AI first, falls back to canvas if Gemini fails
 */
async function generatePost(options) {
  const useGemini = options.useGemini !== false; // default true

  if (useGemini) {
    try {
      return await generateWithGemini(options);
    } catch (err) {
      console.error('[ImageGen] Gemini failed, falling back to canvas:', err.message);
      return generateWithCanvas(options);
    }
  }

  return generateWithCanvas(options);
}

module.exports = { generatePost, generateWithGemini, generateWithCanvas, loadTemplate };