import sharp from 'sharp';
import path from 'path';
import fs from 'fs/promises';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { IMAGE_PROCESSING } from '../../core/constants/index.js';
import { logger } from '../../core/logger.js';
const execFileAsync = promisify(execFile);
const HEIC_EXTENSIONS = ['.heic', '.heif'];
export interface ProcessedImageResult {
contentType: 'post' | 'story' | 'pin';
outputPath: string;
width: number;
height: number;
}
/**
* Sharp-based image processor for converting and center-cropping images
* to platform-specific aspect ratios.
*/
export class SharpProcessor {
/**
* Process a source image for the given content types.
* Converts to JPEG and center-crops to the target aspect ratio.
* Always overwrites existing output files.
*/
async processImage(
sourcePath: string,
contentTypes: Array<'post' | 'story' | 'pin'>
): Promise<ProcessedImageResult[]> {
// Convert HEIC/HEIF to JPG via macOS sips (sharp may lack HEIF support)
const ext = path.extname(sourcePath).toLowerCase();
let effectivePath = sourcePath;
if (HEIC_EXTENSIONS.includes(ext)) {
effectivePath = await this.convertHeicToJpg(sourcePath);
}
// Read source image metadata
const image = sharp(effectivePath);
const metadata = await image.metadata();
if (!metadata.width || !metadata.height) {
throw new Error(`Cannot read dimensions of image: ${sourcePath}`);
}
const sourceWidth = metadata.width;
const sourceHeight = metadata.height;
const baseName = path.parse(path.basename(sourcePath)).name + '.jpg';
const results: ProcessedImageResult[] = [];
for (const contentType of contentTypes) {
const outputDir = path.join(IMAGE_PROCESSING.PROCESSED_DIR, contentType);
const outputPath = path.join(outputDir, baseName);
await fs.mkdir(outputDir, { recursive: true });
const ratio = IMAGE_PROCESSING.ASPECT_RATIOS[contentType];
const { left, top, width, height } = computeCenterCrop(
sourceWidth,
sourceHeight,
ratio.w,
ratio.h
);
logger.info('Processing image', {
contentType,
source: path.basename(sourcePath),
crop: `${width}x${height}`,
output: outputPath,
});
await sharp(effectivePath)
.extract({ left, top, width, height })
.jpeg({ quality: IMAGE_PROCESSING.JPEG_QUALITY })
.toFile(outputPath);
results.push({ contentType, outputPath, width, height });
}
return results;
}
/**
* Convert HEIC/HEIF to JPG using macOS sips command.
* Returns the path to the converted JPG file in .processed/ directory.
*/
private async convertHeicToJpg(sourcePath: string): Promise<string> {
const convertDir = path.join(IMAGE_PROCESSING.PROCESSED_DIR, '_converted');
await fs.mkdir(convertDir, { recursive: true });
const jpgName = path.parse(path.basename(sourcePath)).name + '.jpg';
const convertedPath = path.join(convertDir, jpgName);
logger.info('Converting HEIC to JPG via sips', {
source: path.basename(sourcePath),
output: convertedPath,
});
await execFileAsync('sips', [
'-s', 'format', 'jpeg',
'-s', 'formatOptions', String(IMAGE_PROCESSING.JPEG_QUALITY),
sourcePath,
'--out', convertedPath,
]);
return convertedPath;
}
}
/**
* Compute center-crop region for a given aspect ratio.
*/
function computeCenterCrop(
srcW: number,
srcH: number,
ratioW: number,
ratioH: number
): { left: number; top: number; width: number; height: number } {
const targetAspect = ratioW / ratioH;
const srcAspect = srcW / srcH;
let cropW: number;
let cropH: number;
if (srcAspect > targetAspect) {
// Source is wider than target — crop width
cropH = srcH;
cropW = Math.round(srcH * targetAspect);
} else {
// Source is taller than target — crop height
cropW = srcW;
cropH = Math.round(srcW / targetAspect);
}
const left = Math.round((srcW - cropW) / 2);
const top = Math.round((srcH - cropH) / 2);
return { left, top, width: cropW, height: cropH };
}
export const sharpProcessor = new SharpProcessor();