Skip to content

画像を圧縮する Raycast extension を自作してみた

目次

画像圧縮を簡単に行うための Raycast extension を作ってみたので、その経緯や実装についてメモを残しておきます。

🎧 音声概要 (※ NotebookLM で生成されており、誤りを含む場合があります)

やりたいこと

今回作ったのは「最後にクリップボードにコピーした画像を圧縮し、再度クリップボードにコピーして利用できるようにする」という拡張コマンド (Compress the last copied image) です。

具体的には以下のような流れで使うことを想定しています:

  1. 圧縮したい画像をクリップボードにコピーする
  2. Raycast から Compress the last copied image コマンドを実行する
  3. 圧縮された画像がクリップボードにコピーされる
  4. 好きな場所にペーストする

作ろうと思った背景としては、スクリーンショットや添付したい画像などを扱う際に、過度にファイルサイズが大きすぎるシーンがままあり気になっていた、というのがあります。 特に、ブログなどに画像を添付する場合は画像サイズは小さいに越したことはないので、さっと画像を圧縮できる仕組みがほしいなと考えていました。

Raycast Store にも圧縮を可能にする Extension はいくつか公開されていますが、得体の知れない外部サービスに依存していたり、自分が求めている使い心地でなかったりするので躊躇っていました。 他にも Squoosh など便利に使えるツールはいくつかありますが、しっくり来ていませんでした。

作成の流れ

Extension の骨子を用意する

Raycast の extension 開発を始めるのは非常に簡単です。

Raycast 上でおもむろに Create Extension と入力し、拡張作成コマンドを実行することで開始できます。 Extension のタイトルや説明、コマンド名などを入力していくと、開発に必要なコードが自動的に生成(scaffold)されます。

Raycast Create Extension コマンド

Fig. 1 create extension とおもむろに打ち込む

詳しくは公式ドキュメントも是非参照してください。

ライブラリをインストールする

今回作成する Extension では、主に以下の2つのライブラリを利用します。

  • sharp
    • 画像圧縮ライブラリ
    • WebP 形式に圧縮できる
  • nanoid
    • ユニーク ID を作成するための軽量ライブラリ

インストールしておきます。

npm install sharp nanoid

実装する

実装するコードは大きく以下の2つです。

  • src/compress-the-last-copied-image.ts
    • Extension コマンドの実体
  • scripts/compress-single-image.js
    • Sharp を使って画像を圧縮するスクリプト

Sharp ライブラリが、Raycast コマンドが実行されるランタイムでうまく動作しない [1] ため Sharp を使うスクリプトを分離しています。 このスクリプトを、Extension 側から child_process 経由で実行する形を取っています。

下記にそれぞれ示します。 (勢いで実装しているので多少雑なところがあります、ご容赦ください)

src/compress-the-last-copied-image.ts
import { Clipboard, showToast, Toast } from "@raycast/api";
import path from "path";
import os from "os";
import { nanoid } from "nanoid";
import { execSync } from "node:child_process";

const compressFile = async (inputPath: string, outputPath: string) => {
  // node のバージョン管理に nvm を使っているため、nvm.sh のパスを構築
  const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), ".nvm");
  const nvmScriptPath = path.join(nvmDir, "nvm.sh");

  const scriptDir = "/path/to/raycast/project/image-compresser/scripts";
  const scriptFile = "compress-single-image.js";
  const actualCommand = `\\. "${nvmScriptPath}" && cd "${scriptDir}" && node "${scriptFile}" "${inputPath}" -o "${outputPath}"`;
  execSync(actualCommand, { shell: process.env.SHELL || "/bin/zsh" });
};

export default async function main() {
  try {
    await showToast(Toast.Style.Animated, "Reading clipboard...");

    const clipboardContent = await Clipboard.read();

    if (!clipboardContent.file) {
      await showToast(Toast.Style.Failure, "No file found in clipboard.");
      return;
    }

    let inputPath = clipboardContent.file;

    // file:// スキームが付いている場合があるため除去
    if (inputPath.startsWith("file://")) {
      inputPath = decodeURI(inputPath.substring(7));
    } else {
      inputPath = decodeURI(inputPath);
    }

    await showToast(Toast.Style.Animated, "Compressing image...");

    // --- 出力パスの生成 ---
    const tempDir = os.tmpdir();
    const uniqueId = nanoid(8); // 8文字のランダムID
    const outputFilename = `compressed-${uniqueId}.webp`;
    const outputPath = path.join(tempDir, outputFilename);
    console.log(`Output file path: ${outputPath}`);

    // --- sharp で圧縮処理 ---
    try {
      compressFile(inputPath, outputPath);
      console.log(`Image compressed and saved to: ${outputPath}`);
    } catch (sharpError) {
      console.error("Sharp processing error:", sharpError);
      await showToast(Toast.Style.Failure, "Failed to compress image.", String(sharpError));
      return;
    }

    // --- 圧縮結果をクリップボードにコピー ---
    await showToast(Toast.Style.Animated, "Copying compressed image...");
    try {
      await Clipboard.copy({ file: outputPath });
      await showToast(Toast.Style.Success, "Compressed image copied to clipboard!");
    } catch (copyError) {
      console.error("Clipboard copy error:", copyError);
      await showToast(Toast.Style.Failure, "Failed to copy compressed image.", String(copyError));
    }
  } catch (error) {
    console.error("Overall error:", error);
    let errorMessage = "An unknown error occurred.";
    if (error instanceof Error) {
      errorMessage = error.message;
    } else if (typeof error === "string") {
      errorMessage = error;
    }
    await showToast(Toast.Style.Failure, "Error processing clipboard image", errorMessage);
  }
}
scripts/compress-single-image.js
import fs from "fs";
import path from "path";
import sharp from "sharp";

// デフォルトの圧縮品質
const defaultQuality = 80;

// コマンドライン引数の処理
const args = process.argv.slice(2);
let inputFilePath = null;
let outputFilePath = null;
let quality = defaultQuality;

// 使用方法の表示関数
function printUsage() {
  console.log(`
使用方法:
  node compress-single-image.js <入力画像ファイルパス> [オプション]

オプション:
  -o, --output <出力ファイルパス>  出力ファイルパスを指定 (デフォルト: <入力ファイル名>.webp in ./out)
  -q, --quality <1-100>         圧縮品質を指定 (デフォルト: ${defaultQuality})
  -h, --help                    使用方法の表示

例:
  node compress-single-image.js ./my-image.png
  node compress-single-image.js ./my-image.png -o ./compressed/new-image.webp -q 75
`);
}

// 引数の解析
for (let i = 0; i < args.length; i++) {
  if (args[i] === "--output" || args[i] === "-o") {
    outputFilePath = args[i + 1];
    i++;
  } else if (args[i] === "--quality" || args[i] === "-q") {
    quality = parseInt(args[i + 1], 10);
    i++;
  } else if (args[i] === "--help" || args[i] === "-h") {
    printUsage();
    process.exit(0);
  } else if (!inputFilePath) {
    // オプション以外の最初の引数を入力ファイルパスとする
    inputFilePath = args[i];
  }
}

// 入力ファイルパスが指定されていない場合は使用方法を表示して終了
if (!inputFilePath) {
  console.error("エラー: 入力画像ファイルパスを指定してください。");
  printUsage();
  process.exit(1);
}

// 入力ファイルの存在チェック
if (!fs.existsSync(inputFilePath)) {
  console.error(`エラー: 入力ファイル "${inputFilePath}" が存在しません。`);
  process.exit(1);
}

// 出力ファイルパスが指定されていない場合のデフォルト設定
if (!outputFilePath) {
  const outputDir = "out";
  // 出力ディレクトリが存在しない場合は作成
  if (!fs.existsSync(outputDir)) {
    try {
      fs.mkdirSync(outputDir, { recursive: true });
      console.log(`出力ディレクトリ "${outputDir}" を作成しました。`);
    } catch (err) {
      console.error(`エラー: 出力ディレクトリ "${outputDir}" の作成に失敗しました。`, err);
      process.exit(1);
    }
  }
  const inputFileBaseName = path.basename(inputFilePath, path.extname(inputFilePath));
  const outputFileName = `${inputFileBaseName}.webp`;
  outputFilePath = path.join(outputDir, outputFileName);
} else {
  // 指定された出力パスのディレクトリが存在しない場合は作成
  const outputDir = path.dirname(outputFilePath);
  if (!fs.existsSync(outputDir)) {
    try {
      fs.mkdirSync(outputDir, { recursive: true });
      console.log(`出力ディレクトリ "${outputDir}" を作成しました。`);
    } catch (err) {
      console.error(`エラー: 出力ディレクトリ "${outputDir}" の作成に失敗しました。`, err);
      process.exit(1);
    }
  }
}

// 画像を圧縮してWebP形式で保存する関数
async function compressToWebp() {
  console.log(`"${inputFilePath}" を WebP 形式に圧縮中...`);
  try {
    const image = sharp(inputFilePath);
    const metadata = await image.metadata();
    const hasAlpha = metadata.channels === 4;

    // WebP形式で圧縮
    // パラメータは適当、チューニングの余地ありです
    const outputBuffer = await image
      .webp({
        quality: quality,
        lossless: false, // 可逆圧縮にする場合は true
        reductionEffort: 6, // 圧縮処理の負荷 (0-6)
        alphaQuality: hasAlpha ? 100 : 80, // 透明度がある場合の品質
      })
      .toBuffer();

    // 圧縮された画像をファイルに保存
    fs.writeFileSync(outputFilePath, outputBuffer);

    // 結果を表示
    const inputSize = fs.statSync(inputFilePath).size;
    const outputSize = outputBuffer.length;
    const reduction = ((1 - outputSize / inputSize) * 100).toFixed(2);

    console.log(`✓ 圧縮完了: "${outputFilePath}" (削減率: ${reduction}%)`);
    console.log(`  圧縮前: ${(inputSize / 1024).toFixed(2)} KB`);
    console.log(`  圧縮後: ${(outputSize / 1024).toFixed(2)} KB`);
  } catch (error) {
    console.error(`✗ "${inputFilePath}" の圧縮に失敗しました:`, error.message);
    process.exit(1);
  }
}

compressToWebp();

実際に実行してみる

開発サーバーは以下のコマンドで立ち上げることができます。

npm run dev

実際にこの extension を使って画像を圧縮してみます。 適当にスクリーンショットを撮り、作成したコマンドを実行すると、以下のようにクリップボードに圧縮された画像がコピーされます。

画像圧縮例

Fig. 2 404 kb → 136 kb (約66%削減)

上図のように、画像が圧縮できていることを確認できます [2]

おわりに

以上、クリップボード経由で手軽に画像圧縮を行う Raycast extension を自作した話でした。

今回は画像を圧縮しただけですが、この Extension の仕組みを使えばあらゆるスクリプトを Raycast 上から実行することができるので、工夫次第でいろいろ実現できそうです。

余談ですが、LLM や AI coding ツール の普及でこういうちょっとしたツールをさっと作ることの抵抗感が本当に減ったなと思います。便利な世の中です。

誰かの参考になったら幸いです。 最後までお読みいただきありがとうございました。


[1]
具体的には Error: Could not load the "sharp" module using the darwin-arm64 runtime というエラーが出ました。少し解決に取り組みましたが沼りそうだったので断念しました。そのため本記事では、Raycast が作成する build には含まれない、ローカルのスクリプトを child_process で実行するという、若干トリッキーな強引なアプローチを取っています。ご理解の上、お読みいただけますと幸いです。解決方法をご存じの方がいたら是非ご教示ください...
[2]
ちなみに Fig. 2 の画像も圧縮しており、画像編集ソフトで export した直後の圧縮前 635KB から 35KB まで圧縮できました