src_Converter.js

/**
 * @file Converter.js
 * @description WebP to GIF/PNG converter with support for animated and static WebP files
 * @author caed0
 * @version 2.1.2
 */

const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { Image } = require("node-webpmux");
const GIFEncoder = require('gif-encoder-2');
const { loadImage, createCanvas } = require('canvas');
const os = require("os");

/**
 * Promisified version of execFile for async/await usage
 * @function
 * @private
 * @async
 * @param {string} file - Path to executable file
 * @param {string[]} args - Arguments to pass to the executable
 * @returns {Promise<void>} Resolves when execution completes
 * @throws {Error} When the child process exits with an error
 */
const execFileAsync = (file, args) => {
  return new Promise((resolve, reject) => {
    execFile(file, args, (error) => {
      if (error) reject(error);
      else resolve();
    });
  });
};

/**
 * Waits for the specified number of frames to be extracted to a folder.
 * @function
 * @private
 * @async
 * @param {string} folder - Path to folder containing extracted frames
 * @param {number} expectedCount - Expected number of frames
 * @returns {Promise<void>} Resolves when all frames are available
 */
const waitForFrames = async (folder, expectedCount) => {
  while (fs.readdirSync(folder).length !== expectedCount) {
    await new Promise(resolve => setTimeout(resolve, 50));
  }
};

/**
 * Conversion options for GIF/PNG output.
 * @typedef {Object} ConverterOptions
 * @property {number} [quality=10] - GIF quality (1-100, lower = smaller file)
 * @property {string} [transparent='0x000000'] - Transparent color as `0xRRGGBB`.
 */

/**
 * A single conversion job describing input and desired output.
 * @typedef {Object} Job
 * @property {string} input - Path to input WebP file
 * @property {string} [output] - Output path; auto-generated when omitted
 * @property {ConverterOptions} [settings] - Per-job conversion settings
 */

/**
 * WebP to GIF/PNG converter class.
 * @class
 * @classdesc Provides high-level APIs to convert static and animated WebP images
 * into PNG or GIF outputs. For multiple conversions, prefer {@link Converter#convertJobs}.
 * @category Public API
 */
class Converter {
  /**
  * Path to libwebp binaries directory
  * @type {string}
  * @private
   */
  #BINARIES = path.join(path.resolve(__dirname, '..'), 'libwebp', 'bin');
  
  /**
  * Path to anim_dump executable
  * @type {string}
  * @private
   */
  #ANIM_DUMP = path.join(this.#BINARIES, 'anim_dump');
  
  /**
  * Path to dwebp executable
  * @type {string}
  * @private
   */
  #DWEBP = path.join(this.#BINARIES, 'dwebp');
  
  /**
  * Default conversion options
  * @type {Object}
  * @private
   */
  #defaultOptions = {
    quality: 10,
    transparent: '0x000000'
  };

  /**
   * Creates a new Converter instance.
   * @param {ConverterOptions} [defaultOptions={}] - Default options for conversions
   * @example
   * const Converter = require('@caed0/webp-conv');
   * const conv = new Converter({ quality: 80, transparent: '0x000000' });
   */
  constructor(defaultOptions = {}) {
    this.#defaultOptions = { ...this.#defaultOptions, ...defaultOptions };
  }

  /**
   * Convert WebP files using job objects.
   * @category Public API
   * @async
   * @param {(Job|Job[])} jobs - A single job or an array of jobs
   * @returns {Promise<string|string[]>} Output path(s) of converted file(s)
   * @throws {Error} When jobs are missing/invalid or input is not a WebP
   * @example
   * // Single job
   * await converter.convertJobs({
   *   input: 'path/to/input.webp',
   *   output: 'path/to/output.gif',
   *   settings: { quality: 80 }
   * });
   *
   * // Multiple jobs
   * await converter.convertJobs([
   *   { input: 'file1.webp', settings: { quality: 90 } },
   *   { input: 'file2.webp', output: 'custom.png' }
   * ]);
   */
  async convertJobs(jobs) {
    if (!jobs) throw new Error("Jobs parameter is required");
    
    const isArray = Array.isArray(jobs);
    const jobArray = isArray ? jobs : [jobs];
    
    for (const job of jobArray) {
      this.#validateJob(job);
    }
    
    const results = [];
    for (const job of jobArray) {
      const result = await this.#processJob(job);
      results.push(result);
    }
    
    return isArray ? results : results[0];
  }

  /**
   * Validates a job object for required properties and file existence
   * @private
   * @param {Object} job - Job object to validate
   * @param {string} job.input - Path to input WebP file
   * @throws {Error} When job is invalid, input missing, file doesn't exist, or not a WebP file
   */
  #validateJob(job) {
    if (!job || typeof job !== 'object') {
      throw new Error("Job must be an object");
    }
    if (!job.input) {
      throw new Error("Job must have an 'input' property with the path to the input file");
    }
    if (!fs.existsSync(job.input)) {
      throw new Error(`Input file does not exist (${job.input})`);
    }
    if (!fs.statSync(job.input).isFile()) {
      throw new Error(`Input is not a file (${job.input})`);
    }
    if (path.extname(job.input) !== '.webp') {
      throw new Error(`Input file is not a webp file (${job.input})`);
    }
  }

  /**
   * Generates an appropriate output path based on input file and WebP type detection.
   * @private
   * @param {string} inputPath - Path to input WebP file
   * @returns {string} Output path (.gif for animated, .png for static)
   */
  #generateOutputPath(inputPath) {
    const dir = path.dirname(inputPath);
    const basename = path.basename(inputPath, '.webp');
    
    try {
      const buffer = fs.readFileSync(inputPath);
      const isAnimated = buffer.includes(Buffer.from('ANIM'));
      const ext = isAnimated ? '.gif' : '.png';
      return path.join(dir, `${basename}${ext}`);
    } catch (error) {
      return path.join(dir, `${basename}.png`);
    }
  }

  /**
   * Processes a single job by merging options and calling the convert method.
   * @private
   * @async
   * @param {Job} job - Job object to process
   * @returns {Promise<string>} Path to converted file
   */
  async #processJob(job) {
    const input = job.input;
    const output = job.output || this.#generateOutputPath(input);
    const jobOptions = { ...this.#defaultOptions, ...job.settings };
    
    return await this.convert(input, output, jobOptions, true);
  }

  /**
   * Convert a single WebP file to GIF or PNG format.
   * @category Public API
   * @deprecated Use {@link Converter#convertJobs} for job-based processing.
   * @async
   * @param {string} input - Path to input WebP file
   * @param {string} output - Path to output file (.gif or .png)
   * @param {ConverterOptions} [options={}] - Conversion options
   * @param {boolean} [suppressWarning=false] - Internal flag to suppress the deprecation warning
   * @returns {Promise<string>} Path to converted file
   * @throws {Error} If validation fails or conversion errors occur
   * @example
   * // Convert animated WebP to GIF
   * await converter.convert('input.webp', 'output.gif', { quality: 80 });
   *
   * // Convert static WebP to PNG
   * await converter.convert('static.webp', 'output.png');
   */
  async convert(input, output, options = {}, suppressWarning = false) {
    if (!suppressWarning) {
      console.warn('⚠️  WARNING: The convert() method is deprecated. Please use convertJobs() instead for better functionality and job-based processing.');
    }
    
    if (!input) throw new Error("Input is required");
    if (!output) throw new Error("Output is required");
    if (!fs.existsSync(input)) throw new Error(`Input file does not exist (${input})`);
    if (!fs.statSync(input).isFile()) throw new Error("Input is not a file");
    if (path.extname(input) !== '.webp') throw new Error("Input file is not a webp file");
    if (!['.gif', '.png'].includes(path.extname(output))) throw new Error("Output file must be a gif or png");

    const mergedOptions = { ...this.#defaultOptions, ...options };
    const quality = mergedOptions.quality;
    const transparent = mergedOptions.transparent;

    if (output.endsWith('.png')) {
      await execFileAsync(this.#DWEBP, [input, '-o', output]);
      return output;
    }

    const img = new Image();
    await img.load(input);
    await img.initLib();
    const { width, height, data } = img;
    const rawFrames = data.anim.frames;

    const encoder = new GIFEncoder(width, height, 'neuquant', true, rawFrames.length);
    encoder.createReadStream().pipe(fs.createWriteStream(output));
    encoder.start();
    encoder.setRepeat(data.anim.loops);
    encoder.setTransparent(transparent);
    encoder.setQuality(quality);

    const folder = path.join(os.tmpdir(), 'webp-conv', path.basename(input));
    if (fs.existsSync(folder)) {
      try {
        fs.rmSync(folder, { recursive: true });
      } catch (error) {
        // Ignore cleanup errors for existing folder
      }
    }
    fs.mkdirSync(folder, { recursive: true });

    let cleanupAttempts = 0;
    const maxCleanupAttempts = 5;

    /**
     * Attempts to clean up temporary folder with retry logic
     * @private
     */
    const cleanupFolder = () => {
      try {
        if (fs.existsSync(folder)) {
          fs.rmSync(folder, { recursive: true });
        }
      } catch (error) {
        cleanupAttempts++;
        if (cleanupAttempts < maxCleanupAttempts) {
          setTimeout(cleanupFolder, 100);
        }
      }
    };

    try {
      await execFileAsync(this.#ANIM_DUMP, ['-folder', folder, input]);
      await waitForFrames(folder, Object.keys(rawFrames).length);

      const frames = fs.readdirSync(folder).filter(file => path.extname(file) === '.png');
      for (let i = 0; i < frames.length; i++) {
        const framePath = path.join(folder, frames[i]);
        const ctx = createCanvas(width, height).getContext('2d');
        const image = await loadImage(framePath);
        ctx.drawImage(image, 0, 0, width, height);

        const imageData = ctx.getImageData(0, 0, width, height);
        const data = imageData.data;

        for (let j = 0; j < data.length; j += 4) {
          if (data[j + 3] > 0 && data[j + 3] < 128) data[j + 3] = 0;
        }

        ctx.putImageData(imageData, 0, 0);
        encoder.setDelay(rawFrames[i].delay);
        encoder.addFrame(ctx);
      }

      encoder.finish();
      
      await new Promise(resolve => setTimeout(resolve, 100));
      
      cleanupFolder();
    } catch (error) {
      cleanupFolder();
      throw error;
    }
    return output;
  }
}

module.exports = Converter;