【Astro】Paste Imageで実現する、ストレスフリーな画像最適化ワークフロー(PNG→AVIF/WebP自動変換)

VS Code拡張機能「Paste Image」を使ってMarkdownに画像を簡単に貼り付けつつ、裏側でPNGからAVIF/WebPへの最適化、そしてpictureタグへの自動変換まで行う完全自動化ワークフローの構築方法を解説します。

    Loading...

2025-11-24-11-54-49

作業ログを読み込ませてnano-bananaで作ったフローチャート

技術ブログをMarkdownで書く際、「画像の扱い」が面倒だと感じることはありませんか?

スクリーンショットを撮って、ファイル名を決めて保存して、パスを書いて…という作業は、執筆のリズムを崩してしまいます。また、Webサイトのパフォーマンスを考えると、生のPNGをそのまま配信するのではなく、WebPやAVIFといった次世代フォーマットに最適化することも不可欠です。

今回は、Astroで作られたブログ環境において、これらの面倒な作業をすべて自動化し、「ショートカットキーを一発押すだけ」で完結するストレスフリーなワークフローを構築しました。その全容と設定方法を紹介します。

## 目指すゴール

この記事で構築する環境のゴールはシンプルです。

「クリップボードの画像を、cmd+option+v を押すだけでMarkdownに貼り付け完了。裏側で勝手に最適化される」

これだけです。人間は執筆に集中し、面倒なことはすべてスクリプトに任せます。

### 実現する機能

  1. ワンタッチ貼り付け: VS Code拡張機能「Paste Image」を使い、ショートカットキーで画像をMarkdownに挿入。
  2. 自動最適化: 保存されたPNG画像を検知し、軽量なAVIFとWebPフォーマットを自動生成。
  3. ディレクトリ分離戦略:
    • 人間が管理する元画像(PNG)は src/ 以下に保存。
    • 配信用の最適化画像(AVIF/WebP/PNG)はスクリプトが public/ 以下に自動生成・ミラーリング。
  4. <picture>タグ自動変換: Markdownの画像記法を、ブラウザが最適な形式を選べる <picture> タグのHTMLに自動変換。
  5. 開発時の自動同期: npm run dev 中に画像が追加されたら、即座に変換スクリプトを走らせる。

### 成果物のイメージ

クリップボードに画像がある状態で cmd+option+v を押すと、Markdownには以下のように入力されます。

2025-11-24-01-03-20

![画像の説明](/blog/img/2025-America/Y-MM-DD-hh-mm-ss.png "480x0")

これがビルド時には、以下のようなAVIF -> WebP -> PNGの優先順位で表示可能なHTMLに変換されて出力されます。(“480x0” は高さ指定として処理されます)

<picture>
  <source srcset="/blog/img/.../image.avif" type="image/avif" />
  <source srcset="/blog/img/.../image.webp" type="image/webp" />
  <img
    src="/blog/img/.../image.png"
    alt="画像の説明"
    height="480"
    loading="lazy"
    decoding="async"
  />
</picture>

## 構築ステップ

では、具体的な構築手順を見ていきましょう。

### Step 1. VS Code拡張「Paste Image」の導入と設定

まず、画像を簡単に貼り付けるためのVS Code拡張機能 Paste Image をインストールします。

この拡張機能のキモは、settings.json で保存先と挿入されるMarkdownのパスを細かく制御できる点です。今回は、「記事ファイルと同じ名前のフォルダ」に画像を保存するように設定します。

.vscode/settings.json(またはユーザー設定)に以下を追加します。

{
  // 画像の保存先:src配下の、記事ファイル名と同名のディレクトリ
  "pasteImage.path": "${projectRoot}/src/content/blog/img/${currentFileNameWithoutExt}",
  // 保存ファイル名:日時ベース
  "pasteImage.defaultName": "Y-MM-DD-HH-mm-ss",
  // 挿入されるMarkdownのパターン:public配下のパスとして記述
  "pasteImage.insertPattern": "![${imageFileNameWithoutExt}](/blog/img/${currentFileNameWithoutExt}/${imageFileName} \"480x0\")"
}

#### ポイント

  • pasteImage.path: 元画像はビルド前のソースとして扱いたいので、src/content/blog/img/... に保存します。
  • pasteImage.insertPattern: Markdownには、最終的に配信される public/ 起点のパス(/blog/img/...)を記述しておきます。

### Step 2. PNGからAVIF/WebPを生成する変換スクリプト

次に、src に保存されたPNG画像を元に、最適化されたAVIF/WebPファイルを生成して public ディレクトリに配置するスクリプトを作成します。画像処理には高速な sharp を使用します。

このスクリプトは以下の役割を担います。

  1. public の画像ディレクトリを一旦クリーンアップ。
  2. src 内のPNGファイルを探索。
  3. PNGファイルを public へコピー(ミラーリング)。
  4. 同時に、リサイズしたAVIFとWebP版を作成して public へ保存。
Attention

fast-glob, sharp などのパッケージが必要です。事前にインストールしてください。

convert-images.mjs

import fg from "fast-glob";
import fs from "fs/promises";
import sharp from "sharp";
import path from "node:path";

// 設定
const SRC_DIR = "src/content/blog";
const PUBLIC_DIR = "public/blog";
const MAX_WIDTH = 1600; // 最大幅(これより大きい場合はリサイズ)
const WEBP_QUALITY = 80;
const AVIF_QUALITY = 50;

// メイン処理
async function main() {
  console.log(`Cleaning ${PUBLIC_DIR} ...`);
  await fs.rm(PUBLIC_DIR, { recursive: true, force: true });
  await fs.mkdir(PUBLIC_DIR, { recursive: true });

  const pngFiles = await fg([`${SRC_DIR}/**/*.{png,PNG}`], {
    onlyFiles: true,
  });

  let converted = 0,
    mirrored = 0,
    failed = 0;

  for (const pngPath of pngFiles) {
    try {
      // 1. PNGをpublicへミラーリング
      const rel = path.relative(SRC_DIR, pngPath);
      const publicPngPath = path.join(PUBLIC_DIR, rel);
      await fs.mkdir(path.dirname(publicPngPath), { recursive: true });
      await fs.copyFile(pngPath, publicPngPath);
      mirrored++;

      // 2. sharpで最適化(リサイズ、AVIF/WebP変換)
      const ext = path.extname(publicPngPath);
      const basePublic = publicPngPath.slice(0, -ext.length);
      const publicWebp = `${basePublic}.webp`;
      const publicAvif = `${basePublic}.avif`;

      const img = sharp(publicPngPath);
      const meta = await img.metadata();
      // 必要ならリサイズ
      const resized =
        meta.width && meta.width > MAX_WIDTH
          ? img.resize({ width: MAX_WIDTH, withoutEnlargement: true })
          : img;

      await resized
        .clone()
        .avif({ quality: AVIF_QUALITY })
        .toFile(publicAvif);
      await resized
        .clone()
        .webp({ quality: WEBP_QUALITY })
        .toFile(publicWebp);

      converted += 2;
    } catch (e) {
      failed++;
      console.error("Failed:", pngPath, e.message);
    }
  }

  console.log({ mirrored, converted, failed });
}

main();

### Step 3. Markdownの画像を picture タグに変換するRemarkプラグイン

MarkdownにはPNGのパスが書かれていますが、ブラウザにはAVIFやWebPも提示したいです。そこで、ビルドプロセスでMarkdownの画像記法を <picture> タグのHTMLに置換するRemarkプラグインを作成します。

このプラグインは、Markdownの ![alt](url "title") を解析し、対応する .avif と .webp のソースタグを追加したHTMLに置き換えます。また、タイトルの “480x0” といった記述から width や height 属性を抽出する処理も入れています。

remark-picture.mjs

import { visit } from "unist-util-visit";
import path from "node:path";

export default function remarkPicture(options = {}) {
  const {
    exts = [".png"], // 対象とする拡張子
    imgAttrs = { loading: "lazy", decoding: "async" }, // デフォルト属性
  } = options;

  return (tree) => {
    visit(tree, "image", (node, index, parent) => {
      if (!parent || typeof index !== "number") return;
      if (!node.url) return;

      const url = node.url;
      const ext = path.extname(url).toLowerCase();
      if (!exts.includes(ext)) return;

      // ベースのパスからavif/webpのパスを生成
      const base = url.slice(0, -ext.length);
      const avif = `${base}.avif`;
      const webp = `${base}.webp`;

      const alt = (node.alt ?? "").replace(/"/g, "&quot;");
      const title = (node.title ?? "").replace(/"/g, "&quot;");

      // タイトルからサイズ指定を解析 ("WIDTHxHEIGHT")
      let widthAttr = "";
      let heightAttr = "";
      let styleAttr = "";
      const m = title.match(/^(\d+)\s*x\s*(\d+)$/i);
      if (m) {
        const a = Number(m[1]);
        const b = Number(m[2]);
        if (a > 0 && b > 0) {
          // 両方指定があればwidth/height属性へ
          widthAttr = ` width="${a}"`;
          heightAttr = ` height="${b}"`;
        } else if (a > 0 && b === 0) {
          // 高さだけ0ならmax-heightスタイルへ(例)
          styleAttr = ` style="max-height:${a}px;"`;
        }
      }

      // その他の属性
      const extraAttrs = Object.entries(imgAttrs)
        .map(([k, v]) => ` ${k}="${v}"`)
        .join("");

      // HTMLを生成してノードを置き換え
      const html = `
<picture>
 <source srcset="${avif}" type="image/avif" />
 <source srcset="${webp}" type="image/webp" />
 <img src="${url}" alt="${alt}"${widthAttr}${heightAttr}${styleAttr}${extraAttrs} />
</picture>
`.trim();

      parent.children[index] = { type: "html", value: html };
    });
  };
}

作成したプラグインは astro.config.mjsmarkdown.remarkPlugins に追加して有効化します。

### Step 4. 開発中の自動同期(Watcherの作成)

最後に、開発サーバー(astro dev)を動かしている間、画像が追加されたら自動的に Step 2 の変換スクリプトが走るように監視スクリプトを作成します。これがないと、画像を貼るたびに手動でコマンドを叩くことになり、ストレスフリーになりません。 chokidar を使って src ディレクトリを監視します。

watch-images.mjs

import chokidar from "chokidar";
import { spawn } from "node:child_process";
import path from "node:path";

const WATCH_DIR = "src/content/blog";
const DEBOUNCE_MS = 800; // 連続実行を防ぐためのデバウンス時間

let timer = null;
let running = false;
let queued = false;

// 変換スクリプト(npm run img:opt)を実行する関数
function runConvert() {
  if (running) {
    queued = true;
    return;
  }
  running = true;
  console.log("[img:watch] running img:opt...");

  const p = spawn("npm", ["run", "img:opt"], {
    stdio: "inherit",
    shell: process.platform === "win32",
  });

  p.on("close", (code) => {
    running = false;
    console.log(
      code === 0 ? "[img:watch] done." : "[img:watch] failed",
      code,
    );
    // 実行中に次のリクエストが来ていたら再実行
    if (queued) {
      queued = false;
      runConvert();
    }
  });
}

// デバウンス処理
function debounceRun() {
  if (timer) clearTimeout(timer);
  timer = setTimeout(runConvert, DEBOUNCE_MS);
}

// 監視の設定
const watcher = chokidar.watch(WATCH_DIR, {
  ignoreInitial: true, // 起動時の既存ファイルスキャンは無視
  persistent: true,
  usePolling: true, // 環境によってはポーリングが必要
  interval: 500,
});

// PNGファイルの追加・変更を検知
watcher.on("all", (event, p) => {
  const ext = path.extname(p).toLowerCase();
  if (ext !== ".png" || event === "unlink") return; // 削除は無視するなど調整

  console.log("[img:watch]", event, p);
  debounceRun();
});

console.log(`[img:watch] watching ${WATCH_DIR}...`);

### Step 5. package.json でコマンドをまとめる

作成したスクリプトを package.jsonscripts に登録し、Astroのコマンドと連携させます。

package.json
{
  "scripts": {
    "//": "画像変換の単体コマンド",
    "img:opt": "node scripts/convert-images.mjs",
    "//": "画像監視の単体コマンド",
    "img:watch": "node scripts/watch-images.mjs",

    "//": "devサーバー起動前に一度画像を最適化する",
    "predev": "npm run img:opt",
    "//": "監視スクリプトとAstroの開発サーバーを並列で実行する",
    "dev": "npm run img:watch & astro dev",

    "//": "ビルド前にも必ず画像を最適化する",
    "prebuild": "npm run img:opt",
    "build": "astro build",
    "preview": "astro preview",
    "astro": "astro"
  }
}

これで、普段どおり npm run dev を実行するだけで、画像の監視プロセスも同時に立ち上がるようになりました。

## まとめ

以上のステップで、理想としていた画像ワークフローが完成しました。

  1. 執筆者は、VS Codeで cmd+option+v を押すだけ。
  2. PNGが src/ に保存される。
  3. Watcherがそれを検知し、変換スクリプトを実行。
  4. 最適化された画像(AVIF/WebP/PNG)が public/ に自動生成される。
  5. Astro(Remark)がMarkdownを <picture> タグに変換して表示する。

この仕組みにより、画像の管理コストが劇的に下がり、執筆活動により集中できるようになりました。 また、将来的に別のフレームワークへ移行することになっても、元画像は src にきれいな状態で残っているため、移行コストを最小限に抑えられるというメリットもあります。

Astroでブログを構築している方は、ぜひ試してみてください。