Claude Codeで画像圧縮スキルをPSD対応まで拡張した記録
Claude Codeにはスキルという仕組みがあって、「画像を圧縮して」のような依頼に対して決まった手順を実行させられる。GitHubで公開されている hukusuke1007/agent-skills の中に画像圧縮・リサイズスキルがあったので、Claudeに導入を頼んだ。
導入直後にPythonのバージョンで詰まった
このスキルは元々Python + Pillowで書かれていて、python compress_images.py ... を実行する想定だった。ところが手元の環境には python コマンド自体が無く、python3(3.9系)しか使えなかった。しかもスクリプトは
def resolve_ext(input_path: Path, format_arg: str | None) -> str:のようにPython 3.10以降の型ヒント構文を使っていて、3.9ではそのままだと構文エラーになる。Claudeが原因を切り分けて、ファイル冒頭に
from __future__ import annotationsを1行足すことで解決してくれた。地味だけど、OSSのスキルは持ってきただけでは動かないことがある。
MozJPEG・Oxipng・libwebp・libavifに対応してほしいと頼んだら
導入できたところで、次はフォーマット対応を頼んだ。
JPEG → MozJPEG、PNG → Oxipng、WebP → libwebp、AVIF → libavif の形式変換や圧縮できるように対応してほしい
普通ならHomebrewでそれぞれのライブラリを入れれば済む話だが、Claudeが brew cargo rustc の有無を順に確認したところ、このMacにはどれも入っていなかった。ビルドツール無しでネイティブライブラリを揃えるのは現実的ではない。
Claudeが選んだ方針は次の通り。
- JPEG・WebP・AVIF: Node.jsの
sharpパッケージを使う。ビルド済みバイナリがnpm経由で降ってくるので、コンパイル不要でMozJPEG(mozjpeg: trueオプション)・libwebp・libavifがそのまま使える - PNG:
sharpで一度出力した後、Oxipngの単体バイナリを追加で通す。OxipngはRust製の単一バイナリなので、GitHub Releasesから対象アーキテクチャ向けをダウンロードして配置するだけで動く
エンジンをPython+PillowからNode.js+sharpに書き換えて、4フォーマットへの変換をテストした。8x8〜2400x1600の適当なテスト画像を作って --format jpg/png/webp/avif それぞれ実行し、file コマンドで出力ファイルの中身まで確認した。複数枚の一括処理、同名ファイルがあるときの連番付与(sample.jpg → sample_1.jpg)、透過PNGをJPEGにする際の白背景合成も一通り動作確認済み。
ついでに聞いてみたら、TIFFは sharp の内部エンジン(libvips)がもともと対応していたので、追加対応なしでそのまま変換できた。
本題: PSDは読めるか
Photoshopのpsdファイルを変換できるか聞いてみた。結果はNO。sharp.format を調べると psd というキーが無く、libvipsのPSD対応はImageMagickへの委譲に頼る仕組みだったが、そのImageMagickも入っていない。
対応するにはpsdを読めるライブラリが要る。Claudeが選んだのは ag-psd。Rustのネイティブバイナリと違って純粋なJavaScriptで書かれているので、Homebrewが無い環境でもnpmから入るだけで動く。
ただしここでもう一段詰まりポイントがあった。ag-psd はNode環境だと既定で node-canvas(ネイティブ依存のあるcanvas実装)を要求してきて、素のまま呼ぶと "Canvas not initialized" で例外になる。Claudeが書いたのは、createImageData だけを満たす最小限のフェイクcanvasを自作して登録するという回避策だった。
function fakeCreateCanvas(width, height) {
return {
width,
height,
getContext() {
return {
createImageData(w, h) {
return { width: w, height: h, data: new Uint8ClampedArray(w * h * 4) };
},
putImageData() {},
getImageData(w2, h2) {
return { width: w2, height: h2, data: new Uint8ClampedArray(w2 * h2 * 4) };
},
drawImage() {},
};
},
};
}
initializeCanvas(fakeCreateCanvas);これを readPsd() に useImageData: true を渡して組み合わせることで、レイヤー合成済みの画像(個別レイヤーではなく、Photoshop上で見えるのと同じ1枚絵)をRGBAのピクセル列として取得できるようになった。あとはそれを sharp のraw入力として渡せば、既存のリサイズ・エンコード処理にそのまま乗る。
const psd = readPsd(buffer, {
skipCompositeImageData: false,
skipLayerImageData: true,
skipThumbnail: true,
useImageData: true,
});
const { width, height, data } = psd.imageData;
return sharp(Buffer.from(data.buffer, data.byteOffset, data.byteLength), {
raw: { width, height, channels: 4 },
});テストにも一手間あった。手元にPSDファイルが無く、ImageMagickも無いので作れない。そこでClaudeはPSDファイルフォーマットの仕様書通りにバイナリを手組みして、8x8の非圧縮RGB/RGBAの最小PSDをPythonの struct モジュールで生成した。file コマンドで実際に Adobe Photoshop Image と認識されることを確認した上で、そのファイルを使って変換テストを通していた。透過ありのRGBA版では、アルファ128のピクセルをJPEG変換した際に白背景合成後の値が計算通り(200,100,50,128 → 227,177,152,255)になることまで検証していた。
自分の別リポジトリで公開する前にライセンス確認
一通り動くようになったところで、拡張した内容を自分のGitHubリポジトリにコミットして公開してもらった。ここで一つ確認しておきたくなった。
元のGitHubリポジトリを修正して公開するのは問題ないんだっけ?
Claudeが元リポジトリのライセンスをgh apiで確認したところMITで、README末尾にも「MIT Licenseの下で公開されています」と明記されていた。改変・再配布は問題ないが、MITの唯一の条件は「著作権表示とライセンス全文をコピーに含めること」。最初のコミットにはこれが漏れていたため、元のLICENSEファイルをそのまま取得して同梱し、SKILL.mdに元リポジトリと原作者へのリンクを追記して追加コミットした。地味だけど、OSSを改変して公開するときに忘れがちなポイントだと思う。
最後にオチがついた: force pushのガードレール
公開した2つのコミットは、git のグローバル設定をする前に作られていたので、committer情報が自動検出のホスト名ベースのものになっていた。設定を直した後で「直して」と頼んだところ、Claude Codeの安全機構がここで一度ブロックをかけてきた。
理由は「force pushや履歴書き換えのような破壊的操作は、ユーザー自身がその操作名を明示していないと実行できない」というもの。「直して」という指示だけでは、force pushしていいという明示の同意として弱いという判定だった。実際に「リモートの履歴を書き換えてforce pushしてよいですか?」と聞き直されて、「はい」と答えてようやく実行された。
自分としては「直して」で十分意図は伝わっていたつもりだったが、破壊的な操作の前に一段止まる仕組みがあるのは悪くないなと思った。
実戦投入したら、PSDの9割がCMYKだった
数日後、業務で受け取った2000枚超の画像素材(Google Driveから一括ダウンロードした、tif・psd・heicなどが入り乱れたアーカイブ)を、このスキルでまとめて見られる形式に変換する機会があった。
ところが実行してみると、PSDの変換がほぼ全滅した。原因を調べてもらったところ、157個あったPSDのうち156個が「CMYK」カラーモードだった。印刷用の素材だったので当然といえば当然だが、これまでのテストはすべてRGBのPSDで通していたため、この壁には気づいていなかった。
ag-psdは既定でCMYKを拒否する。ただしソースコードを読んでもらうと、実はCMYKをRGBに変換する処理(cmykToRgb)自体はちゃんと実装済みで、単に許可リストで弾いているだけだった。
// ag-psd/dist/psdReader.js
exports.supportedColorModes = [0 /* Bitmap */, 1 /* Grayscale */, 3 /* RGB */, 2 /* Indexed */];
// ↑ここに 4 (CMYK) が入っていないだけこの配列はモジュールの外からでも書き換えられる公開の配列だったので、起動時に一行足すだけで有効化できた。
import { supportedColorModes } from "ag-psd/dist/psdReader.js";
if (!supportedColorModes.includes(4)) {
supportedColorModes.push(4); // 4 = ColorMode.CMYK
}これで156枚中ほとんどが変換できるようになったが、それでも26枚だけ「Invalid channel count」で失敗が残った。中身を覗くと、CMYKの4チャンネルに加えてアルファチャンネルや特色チャンネルを持つ、5〜6チャンネルのPSDだった。Claudeがデコード処理を読み進めたところ、内部でRLE圧縮データの長さテーブルを読み出す関数が「ファイル内の実チャンネル数」と「取り出したいチャンネル数」を同じ値だと決め打ちしている作りで、チェックだけ外しても長さテーブルの読み違いで画像が壊れるだけだと分かった。ここを直すには関数自体に手を入れる必要があり、Claudeは「26枚のためにライブラリの読み取りロジックへ深入りするリスクの方が大きい」と判断し、非対応として報告してきた。
HEICは読めるのに読めない
もう一つ、iPhoneで撮った.HEICの写真も変換にかけたところ、25枚全部が同じエラーで落ちた。
heif: Error while loading plugin: No decoding plugin installed for this compression format
紛らわしいのが、ファイルのメタデータ(サイズや向きなどの情報)を読むだけなら普通に成功していたことだった。原因は、sharpに同梱されているlibheifが、HEICのコンテナ構造は解釈できても、中身の画素データを圧縮しているHEVC(H.265)コーデックの実際のデコーダーは持っていないという話だった。特許がらみでライセンスが必要なコーデックのため、配布用のビルドにはあえて入れていないらしい。
Homebrewでコーデック込みのlibheifを入れ直す手も本来はあるが、Claudeが目をつけたのはもっと身近なところだった。macOSにはOS標準でsipsという画像変換コマンドが入っていて、これは当然OS自身のHEICデコーダーを使うので、iPhoneで撮った写真も問題なく開ける。
sips -s format jpeg IMG_1234.HEIC --out IMG_1234.jpgというわけで、HEICだけはmacOSではsipsで一旦JPEGに変換してからNode.js側の処理に渡すようにした。25枚とも無事に変換できた。
スキル自体をアップデートした
CMYK PSD対応とHEIC対応は、その場限りの回避策で終わらせず、スキル本体(GitHubで公開しているimage-compressor)に組み込んだ。次に似た画像アーカイブを渡されても、もう同じ壁にはぶつからない。
まとめ
OSSのスキルをそのまま使うだけでなく、手元の環境(Homebrew無し、Python 3.9、canvas無し)に合わせて代替手段を探しながら機能を足していく作業を、一つ一つ実機で検証させながら進められた。「〜対応してほしい」「〜できる?」と投げるだけで、環境調査・実装方針の選定・テスト用データの手組みまでやってくれるのは便利だった一方、公開時のライセンス確認や破壊的操作の許可のように、人間が最終判断すべき場面ではちゃんと立ち止まってくれた。
実戦投入してみて分かったのは、テストした条件の外側に本番の壁があるということ。RGBのPSDでは通っても、実際の素材はCMYKだらけだった。HEICもメタデータが読めた時点で安心していたら、画素データのデコードは別問題だった。動くところまで確認して満足していたが、実データに触れさせて初めて見えた壁があった。