【Sharp】VipsJpeg: premature end of JPEG image


こんにちは、フリーランスエンジニアの太田雅昭です。

環境

sharp: ^0.34.5

エラー

sharpでjpegを処理する際にエラーになりました。

Error: VipsJpeg: premature end of JPEG image at Sharp.toBuffer

これは困った。

原因と対処

他のJpegファイルは読み込めることから、対象のJpegが破損していたようです。ただしMac Finderのプレビューは表示されてましたので、読み込む方法はあると。

ImageMagickなら読み込めました。詳細は不明ですが、破損したデータでも読み込む仕組みがImageMagickにはあるようです。そこでSharpエラー時はImageMagickで変換をかけることにしました。

ImageMagickのインストール

この方法を使うには、ImageMagickのインストールが必要です。

macOS: brew install imagemagick

コード

import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';

/**
 * BufferからSharpインスタンスを安全に作成する
 * Sharpで読み込みエラーが発生した場合は、ImageMagickで一度PNG変換してから再度Sharpで読み込む
 * @param buffer 画像のバッファ
 * @returns Sharpインスタンス
 */
export async function bufferToSharpSafe(buffer: Buffer) {
  try {
    // まずSharpで読み込みを試みる
    const sh = sharp(buffer);
    // toBuffer()を呼び出して実際に読み込めるか検証
    await sh.toBuffer();
    return sh;
  } catch (error) {
    // Sharpで読み込めない場合は、ImageMagickで変換してから再度Sharpで読み込む
    const newBuffer = await bufferToPngBufferWithMagick(buffer);
    return sharp(newBuffer);
  }
}

/**
 * ImageMagickを使用してBufferをPNG形式のBufferに変換する
 * @param buffer 変換元の画像バッファ
 * @returns PNG形式の画像バッファ
 */
async function bufferToPngBufferWithMagick(buffer: Buffer): Promise<Buffer> {
  // 入力用の一時ファイルを作成
  return withTempFile("tmp", async (inputPath) => {
    // 出力用の一時ファイルを作成
    return withTempFile("png", async (outputPath) => {
      // バッファを一時ファイルに書き込み
      fs.writeFileSync(inputPath, buffer);
      // ImageMagickで変換を実行
      await anyToPngWithMagick({ inputPath, outputPath });
      // 変換後のファイルを読み込んでBufferとして返す
      return fs.readFileSync(outputPath);
    });
  });
}

/**
 * ImageMagickを使用して任意の画像形式をPNGに変換する
 * @param params 入力ファイルパスと出力ファイルパス
 */
async function anyToPngWithMagick(params: { inputPath: string; outputPath: string }) {
  const { inputPath, outputPath } = params;
  return myExec("magick convert", [inputPath, outputPath]);
}

/**
 * 一時ファイルを作成して処理を実行し、完了後に自動的に削除する
 * @param extension 一時ファイルの拡張子
 * @param fn 一時ファイルのパスを受け取って処理を実行する関数
 * @returns 処理結果
 */
async function withTempFile<T>(extension: string, fn: (filePath: string) => Promise<T>): Promise<T> {
  // タイムスタンプとUUIDを使用してユニークな一時ファイル名を生成
  const filePath = path.join(os.tmpdir(), `${Date.now()}-${crypto.randomUUID()}.${extension}`);
  try {
    // 処理を実行
    return await fn(filePath);
  } finally {
    // 処理完了後、一時ファイルを削除(エラーは無視)
    fs.unlink(filePath, () => { });
  }
}

/**
 * 外部コマンドを実行する
 * @param cmd 実行するコマンド
 * @param args コマンドに渡す引数の配列
 * @returns コマンドの実行完了を表すPromise
 */
function myExec(cmd: string, args: string[]) {
  return new Promise<void>((resolve, reject) => {
    console.log(`Executing: ${cmd} ${args.join(" ")}`);
    const proc = spawn(cmd, args, { stdio: "pipe" });

    let stderrBuf = "";
    let stdoutBuf = "";

    // 標準出力をキャプチャ
    proc.stdout.on("data", (d) => {
      const output = d?.toString?.() ?? "";
      stdoutBuf += output;
      console.log(`[${cmd}] stdout:`, output.trim());
    });

    // 標準エラー出力をキャプチャ
    proc.stderr.on("data", (d) => {
      const output = d?.toString?.() ?? "";
      stderrBuf += output;
      // console.error(`[${cmd}] stderr:`, output.trim());
    });

    // プロセス終了時の処理
    proc.on("close", code => {
      if (code === 0) {
        // 正常終了
        console.log(`Done: ${cmd} ${args.join(" ")}`);
        resolve();
      } else {
        // 異常終了:エラーメッセージを構築
        const errorMsg = `Command "${cmd} ${args.join(" ")}" failed with exit code ${code}${
          stderrBuf ? `\nStderr: ${stderrBuf.trim()}` : ""
        }${stdoutBuf ? `\nStdout: ${stdoutBuf.trim()}` : ""}`;
        console.error(errorMsg);
        reject(new Error(errorMsg));
      }
    });

    // プロセス起動エラー時の処理
    proc.on("error", (err) => {
      const errorMsg = `Failed to spawn "${cmd}": ${err.message}`;
      console.error(errorMsg);
      reject(new Error(errorMsg));
    });
  });
}

これで安心ですね。