[Astro #58] IndexedDBを活用した外部VRMモデルの永続キャッシュと無限クローゼット化の構築

[Astro #58] IndexedDBを活用した外部VRMモデルの永続キャッシュと無限クローゼット化の構築

はじめに

要塞(PROJECT_LAIN)のデスク環境に、また一つ新しい拡張コードを滑り込ませました。

事の始まりは、ローカル環境のストレージにあるお気に入りの外部VRMモデル(ピンクの衣装のカスタムアバターなど)をブラウザ側へ直接ロードできるようにした際、フロントエンド特有の「刹那的な寿命」に直面したことです。

ファイル入力(<input type="file">)から生成される blob:http://... という一時的なURLは、ブラウザをリロードした瞬間にそのメモリ上のマッピングが完全に消滅してしまいます。結果として、ページをリビルド・リロードするたびにアバターがデフォルトの魔女モデルへとフォールバックしてしまっていました。

この問題を、静的なアセット定義(models.json)の枠組みをほとんど破壊せず、IndexedDB(Dexie.js)によるバイナリの完全トラップと、LocalStorageを起点にした迎撃プロトコルのみでバイパスを繋ぎきりました。

さらに、一度トラップした外部ファイルをメニューの末尾へシームレスに結合し、「無限にアバターを追加・変更できるクローゼット機能」へと昇華させるまでのビルドログをここに残します。

スクショに映ってるモデルデータは VRoid-HUB の マユラさん制作の物です。

サーバにアップせず外部から読み込んで表示しています。

https://hub.vroid.com/users/1364775

PHASE 1: バイナリの完全トラップと永続キャッシュ網の敷設

まず着手したのは、フロントエンドから流れてくる一時的なBlob URLが生きている間に、その実体(バイナリデータ)をデータベース(IndexedDB)の防壁の奥へ引きずり込む処理です。

メニュー側(WiredAvatarMenu.tsx)からファイルがロードされた際、URLのハッシュ断片としてファイル名とファイルサイズを焼き付けたカスタムパスを生成し、下流のキャッシュレイヤー(assetCache.ts)へとキックします。

// WiredAvatarMenu.tsx でのファイルロード処理
const handleFileLoad = (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  if (!file) return;

  const blobUrl = URL.createObjectURL(file);
  // ハッシュ部分にメタデータを仕込んでパイプラインへ流す
  const customPath = `${blobUrl}#name=${encodeURIComponent(file.name)}&size=${file.size}`;

  onModelChange(customPath);
};

これを受け取る assetCache.ts 内に、ハッシュ付きBlob URLを検知した際のインターセプト・ルートを構築しました。すでに同一アセットが IndexedDB に存在すればそこから復元し、存在しなければ生存しているBlob URLからバイナリを fetch して直接ストア(table.put)へ叩き込みます。

// assetCache.ts 内のローカルファイル迎撃ブロック
if (url.startsWith('blob:') && url.includes('#name=')) {
  try {
    const params = new URLSearchParams(url.split('#')[1]);
    const fileName = params.get('name') || 'unknown';
    const fileSize = params.get('size') || '0';
    const cacheKey = `LOCAL_VRM_${fileName}_${fileSize}`;

    // 1. IndexedDBにキャッシュがあるか確認
    const cached = await table.get(cacheKey);
    if (cached) {
      return URL.createObjectURL(cached.data); // バイナリから有効なBlob URLを再生成
    }

    // 2. なければ、現在の有効なBlob URLから実際のバイナリ(Blob)を取得して保存
    const cleanBlobUrl = url.split('#')[0];
    const response = await fetch(cleanBlobUrl);
    const fileBlob = await response.blob();

    await table.put({
      id: cacheKey,
      data: fileBlob,
      version: entry.version || 'local_version'
    });

    // 既存の親コンポーネント(WiredScene.tsx)の永続化キーにキャッシュキーそのものを焼き付ける
    localStorage.setItem('wired_model_path', cacheKey);

    return cleanBlobUrl;
  } catch (err) {
    console.error('[LOCAL_CACHE_ERROR]', err);
    return url.split('#')[0];
  }
}

これで、初回ロード時にアバターのバイナリデータが LOCAL_VRM_ファイル名_サイズ という一意の識別キー(プライマリキー)でIndexedDBに永続化されると同時に、LocalStorage(wired_model_path)の記憶がこのキー文字列へと上書きされるようになります。


PHASE 2: リロード時のインターセプトと復元プロトコル

問題はブラウザをリロードした瞬間です。

親コンポーネント(WiredScene.tsx)はマウント時に LocalStorage から前回の値を復元するため、モデルパスとして LOCAL_VRM_〜 という純粋なキャッシュキー文字列をアバター描画コア(WiredAvatar.tsx)へ流し込んできます。

しかし、この文字列は blob: で始まらないただのテキストです。これをそのまま通常のネットワークアセットとして fetch しにいこうとするとエラーを吐くか、定義外の不正アセットとしてデフォルトアバターへ弾かれてしまいます。

そこで、アバターのロードゲートウェイ(WiredAvatar.tsx)およびキャッシュの展開処理(assetCache.ts)の頭で、このキー文字列を直接トラップして不要な死活監視(fetch)をスルーさせる「復元パス」を開通させました。

// assetCache.ts へ追加したリロード時の直接復元ルート
if (url.startsWith('LOCAL_VRM_')) {
  try {
    const cached = await table.get(url); // キー名で直接DBからバイナリを引き出す
    if (cached) {
      return URL.createObjectURL(cached.data); // 有効なURLへと再展開
    }
  } catch (err) {
    console.error('[LOCAL_KEY_RECOVERY_ERROR]', err);
  }
  return '';
}

同時に WiredAvatar.tsx 側でも、パスが LOCAL_VRM_ 始まりのときは、存在しないネットワーク上のBlobに対する死活チェックをスキップさせるように防壁を調整しました。

これによって、リロード時にも WiredScene(記憶の保持) ➔ WiredAvatar(ゲートスルー) ➔ assetCache(DB展開)のシームレスなパイプラインが繋がり、リロードしてもあのかわいい外部アバターがパッと世界へ復元されるようになりました。


PHASE 3: 静的JSONから動的レコードへの拡張(無限のクローゼット化)

これまでは、システムに組み込まれた models.json という静的なリストに定義されたアバターしかメニューから選択できませんでした。

しかし、IndexedDB の中に「ユーザーが過去に読み込んだ外部アセット」が確実にトラップされているのであれば、メニューを開いた瞬間にそのストアをスキャンし、既存のリストの末尾へと動的にガッチャンコ(結合)してやればいいわけです。

メニュー(WiredAvatarMenu.tsx)を、静的な配列展開から、マウント時に動的レコードを concat するステート管理構造へと拡張しました。

// WiredAvatarMenu.tsx での動的リスト結合処理
const [displayModels, setDisplayModels] = useState<ModelData[]>(models as ModelData[]);

useEffect(() => {
  const loadLocalModels = async () => {
    try {
      const allRecords = await db.models.toArray();
      // キーが \"LOCAL_VRM_\" で始まるレコードのみを抽出
      const localRecords = allRecords.filter(record => record.id.startsWith('LOCAL_VRM_'));

      if (localRecords.length > 0) {
        const convertedLocalModels: ModelData[] = localRecords.map(record => {
          const parts = record.id.split('_');
          let labelName = 'External VRM';
          if (parts.length >= 3) {
            // \"LOCAL_VRM_filename.vrm_12345\" からファイル名部分を抽出
            labelName = parts.slice(2, parts.length - 1).join('_');
          }

          return {
            id: record.id,
            label: decodeURIComponent(labelName),
            path: record.id,
            creator: 'LOCAL_STORAGE',
            isLocal: true
          };
        });

        // 固定のJSONデータ(models)の末尾に、ローカルアバター群を結合
        setDisplayModels([...(models as ModelData[]), ...convertedLocalModels]);
      }
    } catch (err) {
      console.error('[MENU_LOCAL_LOAD_ERROR]', err);
    }
  };

  loadLocalModels();
}, [currentModel]);

メニュー内の描画ループを、固定の models からこの動的な displayModels へと差し替えることで、一度読み込まれた外部VRMモデルが「ドヤ〇ンガちゃん」「ニャロちゃん」「錬金術師の少女」のように、日本語のファイル名そのままにメニューの末尾へとずらりとリスト化されるようになりました。

ラジオボタンを切り替えるだけで、固定のデフォルトモデルと、過去に自分が読み込んできた外部モデルとの間を完全に自由に行き来できます。

ブラウザのストレージ容量が許す限り、外部ファイルを無限に取り込んで、いつでも呼び出せる「自分専用のアバタークローゼット」が要塞のコードベースに完全に組み上がりました。


EPILOGUE: シンプルさという究極の最適化

UI側の表示名を美しく整えるために、フロントエンドのコード側で文字列を正規表現クレンジング(拡張子の削除や余計な空白・コピー判定 (1) の除去など)するアプローチも頭をよぎりました。

しかし、よくよく考えれば「元ファイルの名前自体をはじめから分かりやすいものにリネームしてロードしてあげる」だけで、フロントに無駄な負荷を一切かけずに、データベースのインデックスもメニューのラベルも最も綺麗に完結します。

手を入れるコードは最小限に、しかしシステム的な遊び(不確実性)を無くしてアーキテクチャのポテンシャルを最大化する。夕方に一時的に真っ白に燃え尽きた頭を洗車で冷やし、夜の静寂の中で一気に組み上げたコードのキレは、今思い出しても格段に気持ちが良いものです。

固定されたシステムデータという天井を破り、無限に拡張可能な世界へのバイパスが開通しました。さあ、容量の許す限りお気に入りのアセットを要塞へトラップし、使い倒すとしましょう。