🔑 重要なポイント
✦ AI·GEN「Monaco はリンクしか受け付けず、画像を直接は貼れない」という小さな要件から始まり、画像処理の落とし穴を一本まるごと引き出した記録。Base64 がなぜエディタを固まらせ、かつデータベースを膨張させるのか、貼った瞬間に NAS へ自動アップロードする設計、そして一番見落としやすい地雷——Docker の bind mount は実は「テレポーター」で、HDD がマウントされていないとファイルは静かにシステムディスクへ落ちる。静的な画像ウォールを NAS 動的 API へ書き換える構想と、フォトウォール改修時の「UI 先行、データ後追い」の取捨も収めた。
最近、ブログの管理画面のエディタに Monaco Editor を使い始めました。VS Code の中核であり、コードを書くために生まれたエディタ——それをブログ執筆に使うのだから、少しガチだと自分でも思います。ただこの記事の主役は Monaco 自体ではなく、「その中に画像を一枚入れる」という一見どうでもいい行為のほう。この小さな要件から、Base64、バックグラウンドアップロード、NAS のマウント、そして画像ウォールの書き直しまで、落とし穴が一本まるごと引き出されました。
Monaco はリンクしか食べない、画像は食べない#
Monaco は WYSIWYG エディタとはまったく別世界にいます。Word のような エディタと違って、画像をドラッグしても即表示されない——その世界では、画像はただの Markdown リンクの文字列です。つまり、画像を挿入するには、まず画像を「使える URL」に変えないといけない。当時思いついた道は二つだけ:画像を で直に埋め込むか、どこかへアップロードして URL を作るか。
最初の直感は Base64 でした。画像がそのまま長い文字列になって Markdown に埋まり、テキストと画像が一体化する。記事をあちこち移しても画像が消えない——便利そうに聞こえます:
でも実際に試すと深い落とし穴でした。普通の画像でも Base64 にすると文字列は数十万文字に膨れ上がり、Monaco はこの手の超長い単一行テキストに当たった途端、レンダリングが固まって人生を疑うレベル。おまけにこの巨大なテキストをそのままデータベースに突っ込めば、クエリ性能は落ち、容量は膨れる。この道は明らかに行き止まりです。
そこで業界標準へ戻ります:サーバーへアップロードして URL を作り、Markdown で参照する。記事テキストは軽いまま、DB にはテキストだけ、画像はファイルシステムや NAS、CDN が提供する——読み込みも速く、キャッシュも効かせやすい:
TIP
Base64 は「一番魅力的な一行の解」です——テキストと画像が一体化、依存ゼロ、コピペで即動く。でもコストを、見えない場所に隠している:エディタのレンダリング性能と、DB の容量・クエリ。多くの「簡単な」解は、請求書を先送りして別のどこかに付け替えているだけなのです。
「アップロード」を、書く体験から消す#
方向は決まった。でも手でアップロードし、リンクをコピーし、エディタに貼り戻す——この流れは執筆の連続性を壊しすぎる。理想は、貼ってもドラッグしても、画像が自動でアップロードされ、リンクが生成され、カーソル位置に挿入されて、その過程が「透明」になること。ロジック自体は素直で、四段階に分けられます:
- イベントを監視:Monaco の を掛ける。
- 画像を横取り:クリップボードやドラッグの中身に があるか調べ、純テキストなら通し、画像ならデフォルト動作を止める。
- バックグラウンドでアップロード:その画像を後端
POST /api/uploadへ渡し、後端がファイルを書いて URL を返す。 - カーソルに挿入:フロントは URL を受け取り、 でカーソルのある位置に
を挿入する。
コードにするとだいたいこう(これは当時の設計の下書き):
// 貼り付け時に画像を横取りし、裏でアップロード、URL を受け取ってカーソルに挿入
editor.onDidPaste(async () => {
const file = getImageFromClipboard(); // クリップボードに File Object はある?
if (!file) return; // 純テキストなら通す
const url = await uploadToNAS(file); // POST /api/upload → 画像 URL を返す
editor.executeEdits('paste-image', [{ // カーソル位置に markdown を挿入
range: editor.getSelection(),
text: ``,
}]);
});このアップロードの線は、後で本当に作りました:後端は画像を受け取ると自動で圧縮し WebP へ変換、NAS へ保存し、/uploads/2026/02/<タイムスタンプ>-<乱数>.webp の形の URL を返す——日付でフォルダ分け、タイムスタンプで命名。以来、スクショを一枚貼るのはほぼ一瞬で、執筆の流れは一度も途切れません。
でもファイルは、こっそりシステムディスクに残らない?#
アップロードはできた。でもここで一度立ち止まり、とても自然な疑問が湧きました:私の server はシステムディスク上で動いている。最終的に NAS へ保存するとしても、画像が Node.js の後端を通る時、web 側(システムドライブの SSD)に一部残るのでは?
答えは「残らない」。鍵は です。NAS 上に専用ディレクトリを作り、それをコンテナにマウントする:
services:
blog-backend:
volumes:
# 左が本物の HDD パス、右がコンテナ内のパス
- /mnt/hdd16tb_01/blog_data/images:/app/public/uploadsこうすると、Node.js は画像を /app/public/uploads に書くだけで、ファイルはこのマッピングを通り抜けて、そのまま NAS のセクタに落ちます。は本質的に「テレポーター」:/mnt/hdd16tb_01 配下に書いたものは、システムディスクを 1KB も消費しない——システムドライブは全過程で、ただの「通り道」でしかない。(逆に、Docker に named volume を作らせると、ファイルは /var/lib/docker/volumes/ の下に居座り、そっちがシステムディスクを食います。)
ただしこのテレポーターには、出る杭の唯一の失敗モードがあります:もしその HDD が実はマウントに成功していないと、Linux は /mnt/hdd16tb_01 をただの空フォルダと見なし、そこへ書いたファイルは静かにシステムディスクへ落ちる——エラーは出ず、気づきもしない、ある日システムディスクが謎に埋まるまで。だから毎回まず df -h でマウント状態を確認する:
ファイルシステム サイズ 使用 残り 使用% マウント位置
/dev/nvme0n1p2 1.9T 255G 1.5T 15% /
/dev/sdb1 15T 28K 15T 1% /mnt/hdd16tb_02
/dev/sda1 15T 22G 15T 1% /mnt/hdd16tb_01/mnt/hdd16tb_01、/mnt/hdd16tb_02 があの二つの 15T の HDD に対応している(/ に畳まれていない)のを見て、ようやく安心して画像を書き込めます。最後に、nginx で /uploads/ 始まりのリクエストをディスク上の画像へ直接対応させる(これは当時の構想):
location /uploads/ {
alias /mnt/hdd16tb_01/blog_data/images/;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}WARNING
「ファイルを NAS に保存する」で一番陰湿な失敗は、エラーではなく、エラーが出ないことです。マウントが外れた、パスを打ち間違えた——それでもシステムは素直にファイルをシステムディスクへ書き、すべて正常に見え、システムディスクが爆発するその瞬間まで気づかない。マウントポイント頼みのストレージなら、df -h でのマウント確認を筋肉の記憶にしておく価値があります。
スマホで撮った写真、どう引用する?#
スクショのアップロードは片付いた。でももう一つの場面がある:外でスマホで写真を撮り、NAS へ送った後、それを記事でどう引用するか。ここで私は二つのやり方を天秤にかけました:
やり方一、nginx 静的プロキシ。 NAS 上のある写真フォルダを nginx で公開 URL として晒し、放り込んだ画像はすべて https://koimsurai.com/nas-images/ファイル名.jpg で参照できるようにする。利点はほぼ開発ゼロ——写真が NAS に入った瞬間に参照できる;欠点も明白で、ファイル名を自分で覚えるか NAS からコピーする必要があり、写真が増えると探すのが苦痛で、執筆体験が途切れる。
やり方二、ギャラリーピッカー(当時作りたかった上位版)。 NAS が GET /api/photos を提供し、あるフォルダ下の全画像のファイル名と URL の JSON を返す;そして Monaco 編集エリアの上に「NAS から画像を挿入」ボタンを置き、押すとサムネイル格子の Modal が出て、一枚クリックすれば  がカーソル位置へ自動挿入される。もう手で URL をコピーせず、視覚的に選ぶ。
ツールは単純、体験は段違い。やり方一は繋ぎに向き、やり方二が私の理想形——ただしその時点では、まだ「構想」の枠に留まっていました。
既存の画像ウォール、書き直すべきか?#
この線をたどって、自分の既存の画像ウォールに目を向けました。当時は典型的な「静的ビルド」型:フロントは manifestLoader.ts でハードコードされた /photos-manifest.json を読み、各画像のパス、サムネイル、そして のプレースホルダを並べる;万一 manifest すら見つからないと、Vite の import.meta.glob に降格して、フロント側 assets/Portfolio/ にハードコードされた画像を強引に読む。image-processor.ts も書いていて、自動で圧縮・変換し、Blurhash 用の thumbHash を計算する——が、出力パスは /generated/ のような静的 web フォルダにハードコードされていました。
一番の痛点は:写真を一枚足す手順が、あまりに面倒なこと。画像を一枚増やすたびに、ファイルをプロジェクトの静的ディレクトリへ置き、スクリプトを走らせて photos-manifest.json を更新し、そしてサイト全体を再デプロイする。たとえ猫の写真が一枚増えるだけでも。写真作品自体も容量を食い、1.9T のシステムディスクに詰め込むのも無理がある。
そこで書き直しの設計を描きました(これも当時の構想):
image-processor.tsを NAS のバックグラウンドサービスへ——写真が NAS に上がった瞬間、裏で高画質画像・サムネイル・thumbHashを自動生成する。ただ出力パスを nginx プロキシの URL に変える。GET /api/gallery/photosを一本立てる——元のPhotosManifestDataとまったく同じ構造の JSON 配列を返す。- フロントは無痛で切り替え——
PhotoGallery.tsxはデータソースを一行変えるだけ:
const response = await fetch('/photos-manifest.json');
const response = await fetch('https://koimsurai.com/api/gallery/photos');返す JSON の構造が同一なので、フロントの 、Blurhash のプレースホルダ、hover のインタラクションといったコンポーネントは一行も変えなくていい——データソースが「Web プロジェクトのローカル」から「NAS の動的 API」へ移るだけ。書き換えれば、写真を足すのに再デプロイは不要、NAS に放り込めば勝手に出てきます。
UI 先行、データは後追い#
画像ウォールの見た目のほうは、先に作り直しました。旧版はカードの間隔が広すぎ、しかも「写真作品集」の大見出しカードを一枚被せていて、視覚を魅せるギャラリーというより「フォルダ管理画面」に見えた。Innei の Afilmory を参考に、核は写真を唯一の主役にすること:大きな四角をやめて等高の自適応グリッドへ、タイトルは中央に寄せて締め、hover で隣の写真を暗く沈め、今の一枚だけ浮かせる。作り直すとこうなりました:
そして、綺麗な壁を作り終えて、はたと気づいた:私の写真には、そもそも tag が付いていない。 上に並んだ「すべて / 猫 / 風景」のフィルタボタンは、何一つ絞れないのです。
一本の線が、一枚の壁を引き出す#
振り返れば、この一連は「Monaco はリンクしか食べない」から始まり、Base64 の落とし穴、テレポーターと df -h、そして画像ウォールの書き直しまで引き出しました。すでに動いているアップロードの線を図にすると、だいたいこうなります:
一つ一つの技術的な細部は、単体で見れば孤立しています:Monaco のイベント、Base64 のエンコード、一行の bind mount、一つの df -h。でもそれらは全部、同じ一つのことに奉仕している——「画像を一枚置く」という動作を、書く人にとって自然に、いっそ無感覚にすること。そしてこの旅で一番面白かったのは、一番素朴な教訓でした:一見難解に見える問題(なぜファイルがシステムディスクへ行った?)の答えは、たいてい「まずそれが実際どう動くかをちゃんと理解する」の次の一歩に隠れている。マウントポイントはテレポーター——それが本当に繋がっているか、先に確かめさえすれば。
まだコメントがありません
✨ 最初のコメントを残しませんか