UNPKG

pixel-forge

Version:

A comprehensive generator for social media previews, favicons, and visual assets across all platforms

452 lines (451 loc) 17.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ImageSizes = exports.ImageProcessor = exports.SUPPORTED_INPUT_FORMATS = void 0; exports.enableMockMode = enableMockMode; exports.disableMockMode = disableMockMode; const child_process_1 = require("child_process"); const util_1 = require("util"); const path_1 = __importDefault(require("path")); const fs_1 = require("fs"); const execFileAsync = (0, util_1.promisify)(child_process_1.execFile); // Global flag to enable mock mode for testing let MOCK_MODE = false; // Enable mock mode for testing function enableMockMode() { MOCK_MODE = true; } // Disable mock mode function disableMockMode() { MOCK_MODE = false; } // List of supported input formats exports.SUPPORTED_INPUT_FORMATS = ['.png', '.jpg', '.jpeg', '.webp', '.avif', '.tiff', '.tif', '.gif', '.svg', '.bmp']; class ImageProcessor { constructor(source) { this.tempFiles = []; this.source = source; } /** * Check if ImageMagick is available */ static async checkImageMagick() { if (MOCK_MODE) { return true; // Always return true in mock mode } try { await execFileAsync('magick', ['-version']); return true; } catch (_error) { try { // Fallback to legacy 'convert' command await execFileAsync('convert', ['-version']); return true; } catch (_fallbackError) { return false; } } } /** * Get the appropriate ImageMagick command */ static async getMagickCommand() { if (MOCK_MODE) { return 'mock-magick'; // Return dummy command in mock mode } try { await execFileAsync('magick', ['-version']); return 'magick'; } catch (_error) { // Fallback to legacy 'convert' command return 'convert'; } } /** * Create a mock output file for testing */ async createMockOutputFile(outputPath) { // Create a temp path if none provided const tempOutput = outputPath || path_1.default.join(path_1.default.dirname(this.source), `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.png`); // In mock mode, just copy the source file to the output path try { // Ensure directory exists await fs_1.promises.mkdir(path_1.default.dirname(tempOutput), { recursive: true }); // Copy source to output in mock mode await fs_1.promises.copyFile(this.source, tempOutput); if (!outputPath) { this.tempFiles.push(tempOutput); } return tempOutput; } catch (_error) { // If copy fails (e.g., source doesn't exist), create an empty file await fs_1.promises.writeFile(tempOutput, ''); if (!outputPath) { this.tempFiles.push(tempOutput); } return tempOutput; } } /** * Resize image to specific dimensions using ImageMagick */ async resize(width, height, options = {}) { const { fit = 'cover', background = 'transparent', zoom = 1.0 } = options; // Use mock implementation in test mode if (MOCK_MODE) { return this.createMockOutputFile(); } const tempOutput = path_1.default.join(path_1.default.dirname(this.source), `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.png`); this.tempFiles.push(tempOutput); const magickCmd = await ImageProcessor.getMagickCommand(); const args = []; // Input file args.push(this.source); // Apply high-quality filtering for better transparency handling args.push('-filter', 'lanczos'); // Apply zoom if specified (with transparency preservation) if (zoom !== 1.0) { const zoomPercent = Math.round(zoom * 100); args.push('-resize', `${zoomPercent}%`); } // Handle different fit modes with transparency preservation if (fit === 'contain') { args.push('-resize', `${width}x${height}`); args.push('-gravity', 'center'); // Preserve transparency during extent operation if (background === 'transparent') { args.push('-background', 'none'); } else { args.push('-background', background); } args.push('-extent', `${width}x${height}`); } else if (fit === 'cover') { args.push('-resize', `${width}x${height}^`); args.push('-gravity', 'center'); args.push('-extent', `${width}x${height}`); } else { // fill mode args.push('-resize', `${width}x${height}!`); } // Output file args.push(tempOutput); try { await execFileAsync(magickCmd, args); return tempOutput; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`ImageMagick resize failed: ${errorMessage}`); } } /** * Add text overlay to image using ImageMagick */ async addText(inputFile, options) { const { text, fontSize = 32, color = '#ffffff', position = 'center', offset = { x: 0, y: 0 } } = options; // Use mock implementation in test mode if (MOCK_MODE) { return this.createMockOutputFile(); } const tempOutput = path_1.default.join(path_1.default.dirname(this.source), `temp-text-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.png`); this.tempFiles.push(tempOutput); const magickCmd = await ImageProcessor.getMagickCommand(); const args = []; args.push(inputFile); args.push('-fill', color); args.push('-pointsize', fontSize.toString()); args.push('-gravity', position === 'center' ? 'center' : position === 'top' ? 'north' : 'south'); // Add offset if specified if (offset.x !== 0 || offset.y !== 0) { args.push('-geometry', `+${offset.x}+${offset.y}`); } args.push('-annotate', '0', text); args.push(tempOutput); try { await execFileAsync(magickCmd, args); return tempOutput; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`ImageMagick text overlay failed: ${errorMessage}`); } } /** * Apply color overlay or tint using ImageMagick */ async applyColor(inputFile, color, opacity = 0.5) { // Use mock implementation in test mode if (MOCK_MODE) { return this.createMockOutputFile(); } const tempOutput = path_1.default.join(path_1.default.dirname(this.source), `temp-color-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.png`); this.tempFiles.push(tempOutput); const magickCmd = await ImageProcessor.getMagickCommand(); const args = [ inputFile, '-fill', color, '-colorize', `${Math.round(opacity * 100)}%`, tempOutput ]; try { await execFileAsync(magickCmd, args); return tempOutput; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`ImageMagick color overlay failed: ${errorMessage}`); } } /** * Save image to file with format conversion and transparency preservation */ async save(outputPath, options = {}) { // Use mock implementation in test mode if (MOCK_MODE) { await this.createMockOutputFile(outputPath); return; } // Get format from output path or options let format = path_1.default.extname(outputPath).slice(1).toLowerCase(); if (options.format) { format = options.format; } // Normalize format names if (format === 'jpg') format = 'jpeg'; if (format === 'tif') format = 'tiff'; const quality = options.quality || 90; // Ensure output directory exists await fs_1.promises.mkdir(path_1.default.dirname(outputPath), { recursive: true }); const magickCmd = await ImageProcessor.getMagickCommand(); const args = []; args.push(this.source); // PNG-specific transparency preservation (critical fix from debugging) if (format === 'png') { // Force RGBA transparency preservation - prevents 8-bit colormap conversion args.push('-define', 'png:color-type=6'); // Ensure proper bit depth for transparency args.push('-define', 'png:bit-depth=8'); // Optimize PNG compression if (quality < 95) { // Use adaptive filtering for better compression args.push('-define', 'png:compression-filter=5'); args.push('-define', 'png:compression-level=9'); } // Ensure full range for web images args.push('-define', 'png:format=png32'); } // Set quality for lossy formats if (['jpeg', 'webp', 'avif'].includes(format)) { args.push('-quality', quality.toString()); } // ICO format requires special handling if (format === 'ico') { // Create proper ICO file with multiple sizes args.push('-define', 'icon:auto-resize=256,128,64,48,32,16'); args.push('-compress', 'zip'); } // WebP format optimization if (format === 'webp') { args.push('-define', 'webp:alpha-quality=100'); if (options.background === 'transparent') { args.push('-define', 'webp:alpha-compression=1'); } } // Set output format with proper specification args.push(`${format.toUpperCase()}:${outputPath}`); try { await execFileAsync(magickCmd, args); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`ImageMagick save failed: ${errorMessage}`); } } /** * Generate multiple sizes of the same image */ async generateSizes(sizes, outputDir, options = {}) { // In mock mode, skip ImageMagick check if (!MOCK_MODE) { // Check ImageMagick availability first const isAvailable = await ImageProcessor.checkImageMagick(); if (!isAvailable) { throw new Error('ImageMagick is not installed or not found in PATH. ' + 'Please install ImageMagick:\n' + '- macOS: brew install imagemagick\n' + '- Ubuntu/Debian: apt-get install imagemagick\n' + '- Windows: choco install imagemagick or download from https://imagemagick.org'); } } const results = []; for (const size of sizes) { const promise = (async () => { try { const resizedFile = await this.resize(size.width, size.height, options); const processor = new ImageProcessor(resizedFile); await processor.save(path_1.default.join(outputDir, size.name), options); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to generate size ${size.width}x${size.height}: ${errorMessage}`); } })(); results.push(promise); } try { await Promise.all(results); } finally { // Clean up temporary files await this.cleanup(); } } /** * Create a social media preview with template */ async createSocialPreview(options) { const { width, height, title, description, template = 'basic', background = '#000000' } = options; // Use mock implementation in test mode if (MOCK_MODE) { return this.createMockOutputFile(); } // Start with resized base image let currentFile = await this.resize(width, height, { fit: 'cover', background }); // Add gradient overlay if template is gradient if (template === 'gradient') { currentFile = await this.applyColor(currentFile, 'black', 0.4); } // Add title if provided if (title) { currentFile = await this.addText(currentFile, { text: title, fontSize: Math.floor(height / 10), position: 'center', offset: { x: 0, y: description ? -height / 8 : 0 } }); } // Add description if provided if (description) { currentFile = await this.addText(currentFile, { text: description, fontSize: Math.floor(height / 20), position: 'center', offset: { x: 0, y: height / 8 } }); } return currentFile; } /** * Clean up temporary files */ async cleanup() { const cleanupPromises = this.tempFiles.map(async (file) => { try { await fs_1.promises.unlink(file); } catch (_error) { // Ignore cleanup errors } }); await Promise.allSettled(cleanupPromises); this.tempFiles = []; } } exports.ImageProcessor = ImageProcessor; // Export comprehensive image sizes and formats exports.ImageSizes = { // Standard favicon sizes favicon: [16, 32, 48, 64, 128, 256], apple: [180], android: [192, 512], mstile: [ { width: 70, height: 70 }, { width: 150, height: 150 }, { width: 310, height: 150 }, { width: 310, height: 310 } ], // Social Media Platforms social: { // Standard OpenGraph (1200x630) - Most widely supported standard: { width: 1200, height: 630 }, // Facebook variants facebook: { width: 1200, height: 630 }, facebookSquare: { width: 1200, height: 1200 }, // Twitter variants twitter: { width: 1200, height: 600 }, twitterSquare: { width: 1200, height: 1200 }, // LinkedIn linkedin: { width: 1200, height: 627 }, linkedinCompany: { width: 1104, height: 736 }, // Instagram instagramSquare: { width: 1080, height: 1080 }, instagramPortrait: { width: 1080, height: 1350 }, instagramLandscape: { width: 1080, height: 566 }, instagramStories: { width: 1080, height: 1920 }, // TikTok tiktok: { width: 1080, height: 1920 }, // YouTube youtubeThumbnail: { width: 1280, height: 720 }, youtubeShorts: { width: 1080, height: 1920 }, // Pinterest pinterestPin: { width: 1000, height: 1500 }, pinterestSquare: { width: 1000, height: 1000 }, // Snapchat snapchat: { width: 1080, height: 1920 }, // Emerging platforms threads: { width: 1080, height: 1080 }, bluesky: { width: 1200, height: 630 }, mastodon: { width: 1200, height: 630 } }, // Messaging Apps messaging: { // Standard messaging preview standard: { width: 1200, height: 630 }, // WhatsApp whatsapp: { width: 400, height: 400 }, whatsappLink: { width: 1200, height: 630 }, // iMessage (uses OpenGraph) imessage: { width: 1200, height: 630 }, // Discord discord: { width: 1200, height: 630 }, // Telegram telegram: { width: 1200, height: 630 }, // Signal signal: { width: 1200, height: 630 }, // Slack slack: { width: 1200, height: 630 }, // WeChat wechat: { width: 500, height: 400 }, // Line line: { width: 1200, height: 630 }, // Android RCS androidRcs: { width: 1200, height: 630 }, // Apple Business Chat appleBusinessChat: { width: 1200, height: 630 } }, // Video thumbnail formats video: { youtube: { width: 1280, height: 720 }, vimeo: { width: 1280, height: 720 }, wistia: { width: 1280, height: 720 } }, // Email formats email: { header: { width: 600, height: 200 }, featured: { width: 600, height: 400 } } };