All files / src/lib image-processing.ts

100% Statements 163/163
93.1% Branches 27/29
100% Functions 5/5
100% Lines 163/163

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 1641x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 14x 14x 14x 14x 1x 1x 1x 1x 1x 8x 8x 8x 8x 8x 8x 8x 8x 1x 1x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 1x 1x 1x 1x 13x 13x 13x 13x 13x 13x 13x 13x 4x 4x 9x 13x 7x 1x 1x 7x 8x 8x 8x 1x 1x 1x 1x 1x 4x 4x 4x 4x 8x 6x 6x 6x 3x 3x 3x 3x 8x 4x 1x 1x 1x 1x 1x 7x 7x 6x 6x 6x 6x 7x 4x 4x 7x 7x 4x 4x 4x 7x 1x 1x 7x  
import sharp from "sharp";
import fs from "fs/promises";
import path from "path";
import { logger } from "@/lib/logging";
 
export interface ImageProcessingOptions {
  productId: number;
  imageIndex: number;
  quality?: number;
}
 
export interface ProcessedImage {
  url: string;
  thumbnailUrl: string;
  fullSize: {
    filename: string;
    path: string;
    size: number;
    dimensions: { width: number; height: number };
  };
  thumbnail: {
    filename: string;
    path: string;
    size: number;
    dimensions: { width: number; height: number };
  };
}
 
const IMAGE_DIMENSIONS = {
  FULL: { width: 800, height: 800 },
  THUMBNAIL: { width: 200, height: 200 }};
 
const PRODUCTS_DIR = path.join(process.cwd(), "public", "images", "products");
 
/**
 * Generate filename for product image
 */
function generateFilename(productId: number, imageIndex: number, type: "full" | "thumb"): string {
  const suffix = type === "thumb" ? "-thumb" : "";
  return `product-${productId}-${imageIndex}${suffix}.webp`;
}
 
/**
 * Process uploaded image: create both full-size and thumbnail versions
 */
export async function processProductImage(
  buffer: Buffer,
  options: ImageProcessingOptions
): Promise<ProcessedImage> {
  const { productId, imageIndex, quality = 85 } = options;
 
  // Validate image
  const metadata = await sharp(buffer).metadata();
  if (!metadata.width || !metadata.height) {
    throw new Error("Invalid image file");
  }
 
  // Ensure directory exists
  await fs.mkdir(PRODUCTS_DIR, { recursive: true });
 
  // Process full-size image
  const fullFilename = generateFilename(productId, imageIndex, "full");
  const fullPath = path.join(PRODUCTS_DIR, fullFilename);
  const fullBuffer = await sharp(buffer)
    .resize(IMAGE_DIMENSIONS.FULL.width, IMAGE_DIMENSIONS.FULL.height, {
      fit: "cover",
      position: "center"})
    .webp({ quality })
    .toBuffer();
  await fs.writeFile(fullPath, fullBuffer);
  const fullStats = await fs.stat(fullPath);
 
  // Process thumbnail
  const thumbFilename = generateFilename(productId, imageIndex, "thumb");
  const thumbPath = path.join(PRODUCTS_DIR, thumbFilename);
  const thumbBuffer = await sharp(buffer)
    .resize(IMAGE_DIMENSIONS.THUMBNAIL.width, IMAGE_DIMENSIONS.THUMBNAIL.height, {
      fit: "cover",
      position: "center"})
    .webp({ quality: 80 })
    .toBuffer();
  await fs.writeFile(thumbPath, thumbBuffer);
  const thumbStats = await fs.stat(thumbPath);
 
  return {
    url: `/images/products/${fullFilename}`,
    thumbnailUrl: `/images/products/${thumbFilename}`,
    fullSize: {
      filename: fullFilename,
      path: fullPath,
      size: fullStats.size,
      dimensions: IMAGE_DIMENSIONS.FULL},
    thumbnail: {
      filename: thumbFilename,
      path: thumbPath,
      size: thumbStats.size,
      dimensions: IMAGE_DIMENSIONS.THUMBNAIL}};
}
 
/**
 * Validate image file
 */
export function validateImageFile(
  file: File | Buffer,
  maxSizeMB: number = 10
): { valid: boolean; error?: string } {
  const maxSizeBytes = maxSizeMB * 1024 * 1024;
 
  const size = file instanceof File ? file.size : file.length;
  if (size > maxSizeBytes) {
    return { valid: false, error: `File size exceeds ${maxSizeMB}MB limit` };
  }
 
  if (file instanceof File) {
    if (!file.type.startsWith("image/")) {
      return { valid: false, error: "File must be an image" };
    }
  }
 
  return { valid: true };
}
 
/**
 * Delete product image files (both full and thumbnail)
 */
export async function deleteProductImage(url: string, thumbnailUrl: string): Promise<void> {
  const fullFilename = url.split("/").pop();
  const thumbFilename = thumbnailUrl.split("/").pop();
 
  for (const filename of [fullFilename, thumbFilename]) {
    if (!filename) continue;
    const filepath = path.join(PRODUCTS_DIR, filename);
    try {
      await fs.unlink(filepath);
    } catch (error) {
      logger.error(`Failed to delete file ${filename}`, error instanceof Error ? error : new Error(String(error)), { category: "IMAGE" });
      // Don't throw - file may already be deleted
    }
  }
}
 
/**
 * Get next available image index for a product
 */
export async function getNextImageIndex(productId: number): Promise<number> {
  try {
    const files = await fs.readdir(PRODUCTS_DIR);
    // Match pattern: product-{id}-{index}.webp (not thumbnail)
    const productPattern = new RegExp(`^product-${productId}-(\\d+)\\.webp$`);
    const matchingFiles = files.filter((f) => productPattern.test(f));
 
    if (matchingFiles.length === 0) return 1;
 
    const indices = matchingFiles.map((f) => {
      const match = f.match(productPattern);
      return match ? parseInt(match[1]) : 0;
    });
 
    return Math.max(...indices) + 1;
  } catch {
    return 1; // Default to 1 if directory doesn't exist yet
  }
}