import { Image } from '@nut-tree-fork/nut-js';
import { promises as fs } from 'fs';
import { createCanvas } from 'canvas';
import { Buffer } from 'buffer';
import { logger } from './logger.js';
// Memory monitoring for image processing
let activeCanvases = 0;
let peakMemoryUsage = 0;
function trackCanvasCreation() {
activeCanvases++;
const currentMemory = process.memoryUsage().heapUsed;
if (currentMemory > peakMemoryUsage) {
peakMemoryUsage = currentMemory;
}
if (activeCanvases > 10) {
logger.warn('High number of active canvases detected', {
activeCanvases,
heapUsed: currentMemory,
peakMemory: peakMemoryUsage
});
}
}
function trackCanvasCleanup() {
activeCanvases = Math.max(0, activeCanvases - 1);
}
// Force garbage collection if available
function forceGarbageCollection() {
if (global.gc) {
global.gc();
logger.debug('Forced garbage collection', {
heapUsed: process.memoryUsage().heapUsed,
activeCanvases
});
}
}
export async function imageToBase64(image: Image): Promise<string> {
let canvas: any = null;
let rgbImage: any = null;
try {
trackCanvasCreation();
// Check memory pressure before processing
const memUsage = process.memoryUsage();
const memoryUsageMB = memUsage.heapUsed / 1024 / 1024;
if (memoryUsageMB > 500) { // Over 500MB
logger.warn('High memory usage detected before image processing', {
heapUsedMB: memoryUsageMB,
imageSize: `${image.width}x${image.height}`
});
// Force cleanup if memory usage is critical
if (memoryUsageMB > 800) {
forceGarbageCollection();
}
}
canvas = createCanvas(image.width, image.height);
const ctx = canvas.getContext('2d');
// Convert BGR to RGB and create ImageData
rgbImage = await image.toRGB();
const imageData = ctx.createImageData(rgbImage.width, rgbImage.height);
// Copy pixel data from nut.js Image to canvas ImageData
// The image data is in RGB format with 3 or 4 channels
const sourceData = rgbImage.data;
const destData = imageData.data;
if (rgbImage.channels === 3) {
// RGB format - optimized loop
let srcIdx = 0;
let destIdx = 0;
const pixelCount = rgbImage.width * rgbImage.height;
for (let i = 0; i < pixelCount; i++) {
destData[destIdx++] = sourceData[srcIdx++]; // R
destData[destIdx++] = sourceData[srcIdx++]; // G
destData[destIdx++] = sourceData[srcIdx++]; // B
destData[destIdx++] = 255; // A (fully opaque)
}
} else if (rgbImage.channels === 4) {
// RGBA format - direct copy
destData.set(sourceData);
}
// Put the image data on the canvas
ctx.putImageData(imageData, 0, 0);
// Convert to base64
const base64Result = canvas.toDataURL('image/png');
logger.debug('Image converted to base64', {
originalSize: `${image.width}x${image.height}`,
channels: rgbImage.channels,
base64Length: base64Result.length,
memoryAfter: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1) }MB`
});
return base64Result;
} catch (error) {
logger.error('Failed to convert image to base64', error as Error, {
imageSize: `${image.width}x${image.height}`,
memoryUsage: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1) }MB`
});
throw new Error(`Failed to convert image to base64: ${error}`);
} finally {
// Explicit cleanup
try {
if (rgbImage?.data) {
// Clear the data buffer
rgbImage.data = null;
}
// Canvas cleanup is handled by Node.js GC, but we can null the reference
canvas = null;
rgbImage = null;
trackCanvasCleanup();
// Force GC on every 10th image processing to prevent accumulation
if (activeCanvases % 10 === 0) {
setTimeout(() => forceGarbageCollection(), 0);
}
} catch (cleanupError) {
logger.warn('Error during image processing cleanup', cleanupError as Error);
}
}
}
export async function saveImage(image: Image, outputPath: string): Promise<void> {
let canvas: any = null;
let rgbImage: any = null;
let buffer: Buffer | null = null;
try {
trackCanvasCreation();
canvas = createCanvas(image.width, image.height);
const ctx = canvas.getContext('2d');
// Convert BGR to RGB and create ImageData
rgbImage = await image.toRGB();
const imageData = ctx.createImageData(rgbImage.width, rgbImage.height);
// Copy pixel data from nut.js Image to canvas ImageData
const sourceData = rgbImage.data;
const destData = imageData.data;
if (rgbImage.channels === 3) {
// RGB format - optimized loop
let srcIdx = 0;
let destIdx = 0;
const pixelCount = rgbImage.width * rgbImage.height;
for (let i = 0; i < pixelCount; i++) {
destData[destIdx++] = sourceData[srcIdx++]; // R
destData[destIdx++] = sourceData[srcIdx++]; // G
destData[destIdx++] = sourceData[srcIdx++]; // B
destData[destIdx++] = 255; // A (fully opaque)
}
} else if (rgbImage.channels === 4) {
// RGBA format - direct copy
destData.set(sourceData);
}
// Put the image data on the canvas
ctx.putImageData(imageData, 0, 0);
// Save as PNG
buffer = canvas.toBuffer('image/png');
if (!buffer) {
throw new Error('Failed to convert canvas to buffer');
}
await fs.writeFile(outputPath, buffer);
logger.debug('Image saved successfully', {
outputPath,
imageSize: `${image.width}x${image.height}`,
bufferSize: buffer.length
});
} catch (error) {
logger.error('Failed to save image', error as Error, {
outputPath,
imageSize: `${image.width}x${image.height}`
});
throw new Error(`Failed to save image: ${error}`);
} finally {
// Explicit cleanup
try {
if (rgbImage?.data) {
rgbImage.data = null;
}
// Clear buffer reference
buffer = null;
canvas = null;
rgbImage = null;
trackCanvasCleanup();
// Periodic GC
if (activeCanvases % 5 === 0) {
setTimeout(() => forceGarbageCollection(), 0);
}
} catch (cleanupError) {
logger.warn('Error during image save cleanup', cleanupError as Error);
}
}
}
export function base64ToBuffer(base64: string): Buffer {
try {
// Remove data URI prefix if present
const base64Data = base64.replace(/^data:image\/\w+;base64,/, '');
// Check base64 size for memory monitoring
const estimatedSize = (base64Data.length * 3) / 4; // Approximate decoded size
if (estimatedSize > 50 * 1024 * 1024) { // Over 50MB
logger.warn('Large base64 image detected', {
base64Length: base64Data.length,
estimatedSizeMB: (estimatedSize / 1024 / 1024).toFixed(1)
});
}
return Buffer.from(base64Data, 'base64');
} catch (error) {
logger.error('Failed to convert base64 to buffer', error as Error, {
base64Length: base64.length
});
throw error;
}
}
/**
* Get current image processing memory statistics
*/
export function getImageProcessingStats() {
return {
activeCanvases,
peakMemoryUsage,
currentMemoryUsage: process.memoryUsage().heapUsed
};
}
/**
* Force cleanup of image processing resources
*/
export function cleanupImageProcessing() {
logger.info('Forcing image processing cleanup', {
activeCanvases,
memoryBefore: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1) }MB`
});
forceGarbageCollection();
// Reset counters after cleanup
activeCanvases = 0;
peakMemoryUsage = process.memoryUsage().heapUsed;
logger.info('Image processing cleanup completed', {
memoryAfter: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1) }MB`
});
}