Visual Storeというローカル画像ストレージを作りました。
コーディングエージェントでUIの実装やブラウザーの描画を確認していると、スクリーンショットを何枚も撮ることがあります。その画像をすべて会話へ表示していると、画像そのものだけでなく、会話履歴のデータ量も増えていきます。
今回は、画像を保存することと、AIに画像を見せることを分けるための仕組みを作りました。
きっかけはCodexの長期セッション
以前、Omoikaneの開発をCodexで長時間続けていたところ、デバッグ用のスクリーンショットが会話履歴へ大量に蓄積しました。
そのときの調査内容は、大量の無圧縮PNGを含むCodexの長期セッションでremote compactが失敗したので復旧したに書いています。
有効なコンテキストを調べると、画像は次のような状態でした。
PNG 40枚
画像本体 約42 MB
Base64 data URL 約56 MB
このPNGを画素を変えずに再圧縮したところ、画像本体は約277 KBまで縮みました。その後、同じセッションでremote compactを成功させることもできました。
ただ、この方法はすでに会話履歴へ入ってしまった画像を後から修復するものです。そもそも、デバッグ画像を撮るたびに全部を会話へ入れる必要はありません。
そこで、保存した画像を参照IDで管理し、必要になったときだけ一枚を取り出すことにしました。
保存する画像と見る画像は別
デバッグ中には、次の二つの要求があります。
この画像は後で調べるために保存しておきたい
今この画像をAIに見せたい
これは同じではありません。
例えばブラウザーのレンダリング比較で100枚のスクリーンショットを撮ったとしても、100枚をすべてその場でモデルへ渡す必要はありません。撮影した画像を残し、ラベルやフレーム番号から候補を絞り、差分がありそうな一枚だけ確認すればよい場合もあります。
Visual Storeでは、次のような流れにします。
画像を保存
↓
参照IDだけ受け取る
↓
一覧やメタデータから対象を絞る
↓
必要な一枚だけ取得
↓
その時点で初めて画像を表示
保存しただけでは画像を表示しません。CLIも画像本体やBase64を標準出力へ流さず、小さなJSONだけを返します。
これは、画像ファイルを小さくすればモデルの画像トークンも同じ割合で減る、という話ではありません。画像の保存サイズと、モデルが画像を見たときのコンテキスト消費は別です。Visual Storeが扱うのは、保存した画像を無条件に会話へ入れないことです。
Visual Storeの構成
Visual StoreはRustで実装した単一のローカルCLIです。CLI名はvstoreです。
メタデータはSQLiteで管理し、画像やVP9セグメントの物理データはローカルファイルシステムへ保存します。ネットワークサービスではなく、実行時にFFmpegやImageMagick、APIキー、ネットワーク接続も必要ありません。
画像は物理ファイルのパスではなく、次のような論理参照で扱います。
visual://<store_id>/images/<image_id>
参照IDにはcodecや物理ファイルの場所を入れていません。画像の保存形式がPNGからVP9セグメントへ変わっても、画像のimage_idと参照は変わらないようにしています。
SQLiteでは、観測した画像と、その画像の現在の保存表現を分けています。
images
観測そのもの。image_id、run、stream、frame_no、メタデータ
representations
現在有効なPNGまたはVP9セグメント
segments / frame_locations
VP9セグメントと、その中のフレーム位置
blobs
SHA-256で管理する物理データ
この分離があるので、保存形式の変更と観測履歴の変更を別に扱えます。
CLIの使い方
まずstoreを初期化します。
vstore --store "$PWD/.visual-store" init
画像を保存するときは、既存の撮影・レンダリング処理にPNGファイルを書き出させ、それをputへ渡します。
vstore --store "$PWD/.visual-store" put \
--file artifacts/render.png \
--run ui-check-20260915-a \
--stream browser-main \
--label input-border \
--note "後で確認するため保存"
putの結果には、画像ID、visual://参照、幅、高さ、入力サイズ、保存サイズなどが入ります。画像のバイト列は返しません。
主なコマンドは次のとおりです。
init storeを初期化する
put PNGを観測として登録する
info 参照のメタデータを調べる
list runや登録順から候補を探す
get 参照から一枚のPNGを取り出す
get-frame run、stream、フレーム番号から一枚を取り出す
verify storeと保存データを検証する
pack 複数のPNGをVP9セグメントへまとめる
prune 検証済みの古いPNGを明示的に削除する
migrate v1のstoreをv2へ明示的に移行する
getやget-frameも、取得したパスを返すだけで、画像を表示したとは報告しません。必要なら、そのパスをホスト側の画像表示機能へ渡します。
Codexとの連携
Visual Store本体はCodex専用ではありません。PNGをファイルとして出力できるツールなら利用できます。Omoikaneは最初に使った例の一つですが、Visual Store側にはOmoikane固有の処理は入れていません。
リポジトリにはVisual Store Skillも同梱しています。このSkillがコーディングエージェントへ教えるのは、主に次の使い方です。
画像を保存するときは vstore put を使う
保存しただけでは画像を表示しない
list と info で候補を絞る
必要なときだけ get または get-frame を使う
明示されていない prune や migrate は実行しない
runが完了したことが明確になったときだけpackを実行します。画像を保存しただけで、もう撮影が終わったとは判断しません。
なお、リポジトリのテストではSkillの構造と記述を検証していますが、実際のCodexホストが毎回どのようにSkillを発見して選択するかまでは自動テストしていません。そこはホスト側の確認が必要です。
まずはPNGを保存する
putした画像は、最初はPNGとして保存されます。対応しているのは、非インターレースの8-bit RGB/RGBA PNGです。グレースケール、パレット、16-bit、インターレース、APNGなどは受け付けません。
入力PNGは、signature、chunk長、chunk type、CRC、IHDRやIDATの並び、zlib、フィルター、展開後のサイズと画素を検証します。上限を超えた入力も登録しません。
検証後は、既存のフィルター済み走査線をzlib level 6で再圧縮します。再圧縮した結果が元より小さく、ラウンドトリップ検証も通った場合だけ、再圧縮版を保存します。元の画像の画素は変更しません。
ここでいう可逆は、PNGファイルのバイト列が同じという意味ではありません。IDATの圧縮方法や境界は変わる可能性があります。一方で、デコードしたRGB/RGBAサンプル、フィルターを含む走査線、受け付けたnon-IDAT chunkの内容と相対的な位置は保持します。
入力ファイルそのものを後で取得したい場合は、--keep-sourceを指定します。これは再圧縮された保存表現とは別に、元のバイト列を保持するための指定です。
同じ画像を二度保存したらどうなるか
物理的なPNG blobはSHA-256で重複排除します。ただし、同じ画像を二度登録したという観測記録まで消すわけではありません。
同じ画面が二回観測された
↓
画像レコードは二つ
↓
物理blobは一つを共有
画像の内容が同じでも、二回の観測には別の登録順やフレーム番号があります。再試行を同じ操作として扱いたい場合は--operation-idを使い、別の観測として登録したい場合は別の操作にします。
スクリーンショット列なら時間方向にも圧縮できる
PNGの再圧縮だけでも、今回のような無圧縮に近いPNGには効果があります。
ただ、UIのスクリーンショットを連続して撮ると、画像間にも似た部分が多くあります。
ボタンだけ変わる
カーソルだけ動く
一部の文字だけ変わる
少しスクロールする
エラーメッセージが増える
PNGは一枚の画像の中にある冗長性は圧縮しますが、前のスクリーンショットとの違いまでは使いません。そこで、同じrunとstreamに属する連続した画像を、短い動画セグメントとして保存することにしました。
VP9を使う理由
Visual Storeの時間方向圧縮はVP9です。Rustからlibvpx-native-sysを通じて、system libvpxのencoderとdecoder APIを直接呼び出しています。
FFmpegのコマンドやlibavcodecを呼ぶ構成ではありません。Visual Storeの実行時にFFmpegをインストールする必要もありません。
VP9にしたのは、今回必要な次の条件を一つのcodec APIで扱えるからです。
lossless設定
複数frameを同じencoderへ渡す時間方向予測
encoderとdecoderの直接利用
既定のsegment長は32 frameです。CLIでは2から128の範囲で変更できます。長大な動画ファイル一つへまとめず、短いsegmentに分けることで、必要な範囲だけを復号しやすくし、破損時の影響範囲も抑えます。異なるサイズやRGB/RGBAの画像が混ざった場合も、同じsegmentへ入れません。
packは、putのたびにrun全体をエンコードするものではありません。流れは次のようになっています。
put
↓
PNGとして即時保存
↓
runがまとまったところで明示的にpack
↓
連続するPNGをVP9 segment候補へ変換
↓
encodeとラウンドトリップ検証
↓
小さくなった場合だけVP9表現を有効化
圧縮後の候補がPNGより小さくならなければ、PNGのまま残ります。動画形式へ入れたこと自体を成功とは扱っていません。
AV1については、CLIで--codec av1を指定できますが、現在のbackendは未実装です。指定するとE_CODEC_UNAVAILABLEになり、VP9へ黙って置き換えることはありません。
普通の動画変換とは違う
一般的な動画変換なら、RGBをYUVへ変換し、4:2:0のようなsubsamplingを適用することがあります。しかし、それでは元の画素と完全に一致しなくなる可能性があります。
Visual Storeはデバッグ用画像を保存するものなので、「見た目がほぼ同じ」では足りません。ピクセル差分テストなら、1 byteの違いでも結果が変わります。
そこで、VP9の通常の映像用途とは違うplane配置にしています。
RGB
R → VP9のplane 0
G → VP9のplane 1
B → VP9のplane 2
RGBA
RGB → color stream
A → 別のalpha streamのplane 0
8-bitのfull-resolution I444へ直接格納するため、RGBからYUVへの変換やsubsamplingは行いません。RGBAのalpha streamのplane 1とplane 2は0に固定し、復号時にも検証します。
透明度が0の画素でも、RGB値が0とは限りません。描画や画像処理の途中では、透明になった後もRGB値が残っていることがあります。Visual StoreではそのRGB値も保存対象なので、alpha=0の下にあるRGBを捨てないようにしています。
losslessをどう確認しているか
VP9側でlosslessを指定するだけで終わりにはしていません。
画像の往復では、少なくとも次を検証します。
幅と高さ
RGBまたはRGBAの画素サンプル
alpha
alpha=0の画素にあるRGB
フィルターを含む走査線
non-IDAT chunkの内容と順序
VP9のframeは画素サンプルを保持しますが、PNGのcontainer情報までは保持しません。そこで、元PNGのIHDR、受け付けたnon-IDAT chunkとその位置、各行の元のフィルターを、png_reconstructionという別のdescriptorへ保存します。
復号時には、そのdescriptorを使ってPNGの走査線を再構築し、pixel、scanline、non-IDATのSHA-256を確認してからPNGを組み立てます。組み立てたPNGは、入力時と同じ厳格なPNG検証にも通します。
このため、pack後に得られるPNGは元ファイルと完全に同じバイト列とは限りません。zlibの圧縮結果やIDATの境界は変わることがあります。元ファイルのバイト列そのものが必要な場合は、先ほどの--keep-sourceで別に保持します。
本当に時間方向圧縮しているか
VP9を使ったというだけでは、動画コンテナへ画像を並べただけかもしれません。
そこでテストでは、複数の変化するframeを同じencoderへ順に渡し、先頭がkeyframeで後続にnon-key packetが含まれることを確認しています。さらに、そのnon-key packetだけを新しいdecoderへ渡すと復号に失敗し、先行frameから続けて復号した場合は元の画素へ戻ることも確認しています。
つまり、後続frameが実際に前のdecoder stateへ依存しています。これが時間方向圧縮を使っていることの確認になります。
all-intraのencoderもテスト用にあり、こちらは全frameがkeyframeになります。同じ入力では、時間方向予測を使った通常のVP9の方が小さくなることを確認しています。
frame番号とget-frame
画像列にはrun、stream、frame_noがあります。
run = omoikane-test-001
stream = firefox
frame = 17
runとstreamを指定した観測には、登録順で0から始まるframe番号を割り当てます。この番号はpackしても変わりません。VP9 segment内の物理的なframe_indexとは別の、利用者向けの番号です。
pack後の画像も、参照IDまたはframe番号から一枚のPNGとして取り出せます。
vstore --store "$PWD/.visual-store" get 'visual://STORE_ID/images/IMAGE_ID'
vstore --store "$PWD/.visual-store" get-frame \
--run ui-check-20260915-a \
--stream browser-main \
--frame 17
get-frameは(run, stream, frame_no)のindexから対象を一つ選び、関係のないsegmentは読みません。選んだsegmentのkeyframe、または記録されたdecode開始位置から対象frameまでを復号します。inter frameは前の状態が必要なので、対象frameだけを単独で読めるわけではありませんが、run全体を最初から復号する構成にはしていません。
実測ではどのくらい小さくなったか
リポジトリに入れているベンチマークでは、静的なアプリケーション画面、スクロールする線、動くカーソルを含む640×360のRGB画像32枚を使いました。測定環境はApple M2 MacBook Air、8コア、24 GB、macOS 26.5.1、Rust 1.95.0、libvpx 1.16.0、release build、codec thread 1、segment長32です。
結果は次のとおりでした。
| 表現 | サイズ |
|---|---|
| 入力PNG列 | 22,135,456 bytes |
| PNG再圧縮後のdistinct blob | 89,272 bytes |
| inter-frame VP9一式 | 30,558 bytes |
| all-intra VP9一式 | 62,858 bytes |
ここでinter-frame VP9一式には、VP9のcolor container、PNG再構築descriptor、codec descriptor、packによるSQLite index増加分を含めています。VP9のcolor containerだけなら9,378 bytesですが、実際の保存表現として比較するため関連する増加分を合算しています。
入力PNG列の22 MBから30 KBだけを見ると大きく見えます。しかし、元のPNGは圧縮されていないfixtureなので、PNG再圧縮だけでも89 KBまで縮んでいます。
Visual Storeで時間方向圧縮した効果を見るなら、まず89,272 bytes → 30,558 bytesを見るべきです。このfixtureでは、inter-frame VP9はPNG再圧縮後より65.8%小さく、all-intra VP9より51.4%小さくなりました。
これは画像内容と環境に依存する一例です。一般的な圧縮率として扱える数字ではありません。
取得速度
同じベンチマークで、pack後のframe 17をwarm cacheから20回取得しました。3回のwarmup後のmedianは25.676 ms、p95は30.729 msでした。
このとき復号した範囲は、一つのsegmentのframe 0から17です。segmentの途中にあるinter frameを取るには、参照状態を作るために先行packetを読む必要があります。長いsegmentにすれば圧縮効率が上がる可能性はありますが、途中frameの取得時に読む範囲も増えます。既定値を32 frame程度にしているのは、このあたりのバランスを考えたためです。
packしても元PNGはすぐ消さない
packが成功しても、元のPNG表現はすぐに削除しません。
候補のencodeとラウンドトリップ検証が通ってからVP9表現を有効化しますが、それでも元PNGを残しておけば、問題があった場合に戻せます。packの途中でプロセスが終了しても、未参照の完成した候補が残るだけで、元のactive表現を壊さないようにしています。
削除するときは、まず次のように確認します。
vstore --store "$PWD/.visual-store" prune --dry-run
prune --applyを明示的に実行したときだけ、検証済みで不要になったPNGを削除します。削除はstoreの排他ロックを取り、retained → pending → deletedという状態を記録しながら進めます。途中で終了しても、次の実行で状態を見て再開できます。共有blobは画像ごとではなく一度だけ数え、入力ファイル、source、export、VP9セグメントは削除しません。
ベンチマークでは、pack後の物理オブジェクトが99,059 bytesから9,787 bytesになり、89,272 bytesを回収しました。これもこのfixtureでの測定値です。
v1からv2への移行
時間方向圧縮を入れる前のv1 storeも、読み取りだけならそのまま開けます。古いstoreを開いただけで勝手に書き換えることはありません。
書き込みやpackを行うには、全体をバックアップしたうえで明示的に移行します。
vstore --store PATH migrate --to 2
移行ではSQLiteのバックアップとjournalを作り、データベース、manifest、移行履歴を順番に更新します。途中で止まった場合、通常操作はE_MIGRATION_INCOMPLETEで停止し、--resumeで先へ進めるか、--restoreでv1のバックアップへ戻します。
移行後もstore UUID、画像UUID、登録順、メタデータ、ハッシュ、operation IDなどは維持します。論理参照を保存形式の変更で壊さないための移行です。
壊れないことを先に確認する
画像ストレージなので、圧縮率より先に壊れないことを確認しました。
テストでは、複数プロセスからのput、putとpackとgetの同時実行、operation IDの再試行、途中終了、VP9セグメントや再構築descriptorの破損、v1からv2への移行中断、pruneの各段階での中断などを確認しています。
packは候補を先にファイルへ書き、検証してから短いSQLiteトランザクションでactive表現を切り替えます。commit前に止まった候補は参照されないまま残り、commit後はimmutableなsegmentとして扱います。
verifyではSQLiteの整合性だけでなく、blobのサイズとSHA-256、segment、frame mapping、PNG再構築、画素、走査線、non-IDAT、最終PNGまで確認します。画像本体はJSONへ出力しません。
v1のfixture、macOSとLinux、VP9なしのbuild、リソース上限を超えた入力もテスト対象にしています。VP9なしのbuildではPNGやメタデータは扱えますが、VP9セグメントの取得や完全検証はE_CODEC_UNAVAILABLEになります。
まとめ
Visual Storeを作った理由は、デバッグ画像を圧縮して保存したかったからだけではありません。
画像を保存しておくことと、AIに画像を見せることを分離したかったからです。
putではPNGを検証してローカルへ保存し、小さなJSONとvisual://参照だけを返します。画像列がまとまったらpackで、必要なものだけを時間方向圧縮したVP9 segmentへ変換します。必要な一枚を確認するときだけgetやget-frameを使い、そこで初めて画像表示へ渡します。
VP9の保存表現は、RGBをYUVへ変換する通常の動画処理ではなく、RGB各planeとalpha streamを使った可逆保存です。元PNGのcontainer情報も再構築descriptorへ分けて保持し、画像サンプルだけでなくPNGとしての検証も行います。
今回の実測では、640×360のGUI画像32枚で、PNG再圧縮後の89,272 bytesがinter-frame VP9一式で30,558 bytesになりました。ただし、これは一つのfixtureに対する測定値で、モデルの画像トークン削減量を示すものではありません。
Visual Storeは、すでに会話へ表示した画像を履歴から消すツールでも、Codex内部のセッションを直接操作するツールでもありません。これから保存する画像を、必要なときだけ見せるためのローカルストレージです。