← 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 };