I created a local image storage called Visual Store (source).

When implementing UIs or checking browser rendering with a coding agent, I often end up taking many screenshots. Displaying all those images in the conversation not only adds the images themselves but also increases the amount of data in the conversation history.

This time, I built a system that separates the act of saving images from showing the images to the AI.

The trigger: a long‑running Codex session

Previously, while developing Omoikane with Codex for an extended period, debugging screenshots accumulated massively in the conversation history.

The details of that investigation are documented in “Recovered from a remote‑compact failure in a long‑running Codex session that contained a large number of uncompressed PNGs” (source).

When we examined the valid context, the images were in the following state.

40 PNG images
Image data, approximately 42 MB
Base64 data URL, approximately 56 MB

Recompressing this PNG without altering its pixel data reduced the image itself to about 277 KB. After that, we were also able to succeed with the remote compact in the same session.

However, this method only repairs images that have already been added to the conversation history. In the first place, there’s no need to insert every debugging screenshot into the conversation each time one is taken.

Therefore, we decided to manage stored images by reference ID and retrieve only a single image when it is needed.

Stored images and displayed images are separate

During debugging, there are two distinct requirements.

I want to save this image for later investigation
I want to show this image to the AI now

These are not the same.

For example, even if you take 100 screenshots while comparing browser renderings, you don’t need to hand all 100 images to the model at once. You can keep the captured images, narrow down the candidates using labels or frame numbers, and simply inspect the one frame that appears to have a difference.

In Visual Store, the process works as follows.

Save image
    ↓
Receive only the reference ID
    ↓
Narrow down the target using lists or metadata
    ↓
Retrieve only the necessary single image
    ↓
Display the image for the first time at that point

The image is not displayed merely because it is saved. The CLI does not output the image data or Base64 to stdout; it returns only a small JSON object.

This does not mean that shrinking the image file size will proportionally reduce the model’s image‑token consumption. The storage size of an image and the amount of context the model uses when viewing the image are separate concerns. What Visual Store handles is ensuring that saved images are not automatically added to the conversation.

Visual Store Architecture

Visual Store is a single local CLI implemented in Rust. The CLI’s name is vstore.

Metadata is managed with SQLite, while the physical data for images and VP9 segments is stored on the local file system. It does not rely on any network service, and at runtime it does not require FFmpeg, ImageMagick, API keys, or network connections.

Images are handled via logical references rather than physical file paths, for example:

visual://<store_id>/images/<image_id>

The reference ID does not contain the codec or the physical file location. Even if the image’s storage format changes from PNG to VP9 segments, the reference for the image (image_id) remains unchanged.

In SQLite, the observed image and its current stored representation are kept separate.

images
    The observation itself: image_id, run, stream, frame number, metadata

representations
    Currently valid PNG or VP9 segment

segments / frame_locations
    VP9 segment and the frame position within it

blobs
    Physical data managed with SHA‑256

Because of this separation, changes to the storage format and changes to the observation history can be handled independently.

Using the CLI

First, initialize the store.

vstore --store "$PWD/.visual-store" init

When saving an image, have the existing capture or rendering process output a PNG file, then pass that file to put.

vstore --store "$PWD/.visual-store" put \
  --file artifacts/render.png \
  --run ui-check-20260915-a \
  --stream browser-main \
  --label input-border \
  --note "Save for later review"

The result of put includes the image ID, a visual:// reference, width, height, input size, stored size, and other metadata. The raw image bytes are not returned.

The main commands are as follows.

init       Initialize the store
put        Register a PNG as an observation
info       Inspect the metadata of a reference
list       Find candidates based on run or registration order
get        Retrieve a single PNG from a reference
get-frame  Retrieve a single frame using run, stream, and frame number
verify     Verify the store and stored data
pack       Combine multiple PNGs into a VP9 segment
prune      Explicitly delete verified old PNGs
migrate    Explicitly migrate a v1 store to v2

get and get-frame simply return the retrieved path and do not report that they have displayed the image. If needed, you can pass that path to the host’s image‑display functionality.

Integration with Codex

Visual Store itself is not limited to Codex. Any tool that can output PNG files can be used with it. Omoikane was one of the first examples, but Visual Store contains no Omoikane‑specific logic.

The repository also includes the Visual Store Skill. This Skill primarily teaches the coding agent the following usage:

Use vstore put when saving an image
Saving alone does not display the image
Narrow down candidates using list and info
Use get or get-frame only when needed
Do not execute prune or migrate unless explicitly instructed

Execute pack only when it is clear that the run has completed. Simply saving an image does not imply that the capture is finished.

Note that the repository’s tests verify the structure and description of the Skill, but they do not automatically test how the actual Codex host discovers and selects the Skill each time. That aspect must be verified on the host side.

First, save PNGs

Images saved with put are initially stored as PNG files. Supported formats are non‑interlaced 8‑bit RGB/RGBA PNGs. Grayscale, palette‑based, 16‑bit, interlaced, and APNG formats are not accepted.

Input PNGs are validated for their signature, chunk lengths, chunk types, CRC values, the ordering and IHDR/IDAT chunks, the zlib compression, the filter method, and the post‑decompression size and pixel data. Inputs that exceed the allowed limits are not registered.

After validation, the existing filtered scanlines are recompressed using zlib level 6. The recompressed version is saved only if it results in a smaller file size and passes a round‑trip verification. The original image’s pixel data is not altered.

Here, “lossless” does not mean that the byte sequence of the PNG file remains identical. The IDAT compression method and its boundaries may change. However, the decoded RGB/RGBA samples, the scanlines including their filters, and the contents and relative positions of non‑IDAT chunks are preserved.

If you want to retrieve the original input file later, specify --keep-source. This flag preserves the original byte stream separately from the recompressed stored representation.

What happens if the same image is saved twice

Physical PNG blobs are deduplicated using SHA‑256. However, this does not erase the record that the same image was registered twice.

The same screen was observed twice
        ↓
There are two image records
        ↓
A single physical blob is shared

Even if the image content is identical, the two observations have separate registration orders and frame numbers. If you want to treat a retry as the same operation, use --operation-id; if you want to register it as a distinct observation, use a different operation.

Sequences of screenshots can also be compressed over time

Even recompressing PNGs alone is effective for PNGs that are close to uncompressed, as in this case.

However, when you take consecutive UI screenshots, there are many similar regions between successive images.

Only the button changes
Only the cursor moves
Only part of the text changes
Slight scrolling
Error messages increase

PNG compresses redundancy within a single image, but it does not exploit differences from previous screenshots. Therefore, we decided to store consecutive images belonging to the same run and stream as short video segments.

Why use VP9

Visual Store’s temporal compression uses VP9. From Rust, we call libvpx-native-sys to directly invoke the encoder and decoder APIs of system libvpx.

It does not involve invoking FFmpeg commands or libavcodec. FFmpeg does not need to be installed when running Visual Store.

We chose VP9 because it can handle the following requirements with a single codec API:

Lossless setting
Temporal prediction by feeding multiple frames to the same encoder
Direct use of the encoder and decoder

The default segment length is 32 frames. The CLI allows adjusting this value between 2 and 128 frames. By splitting the content into short segments instead of consolidating everything into a single large video file, it becomes easier to decode only the needed portion and limits the impact of any corruption. Even when images of different sizes or RGB/RGBA formats are mixed, they are not placed in the same segment.

pack does not encode the entire run each time put occurs. The process works as follows:

put
  ↓
Immediate save as PNG
  ↓
Explicitly pack when the run is completed
  ↓
Convert consecutive PNGs into VP9 segment candidates
  ↓
Encoding and round‑trip verification
  ↓
Enable VP9 representation only when the size is reduced

If the compressed candidate is not smaller than the PNG, it remains as a PNG. Simply embedding it in a video format is not considered a success.

For AV1, you can specify --codec av1 via the CLI, but the current backend does not yet support it. Specifying it results in E_CODEC_UNAVAILABLE, and it does not silently replace it with VP9.

Different from ordinary video conversion

In typical video conversion, RGB is converted to YUV and a subsampling such as 4:2:0 may be applied. However, this can cause the result to no longer match the original pixels exactly.

Since Visual Store is used to save debugging images, “visually almost identical” is insufficient. In a pixel‑difference test, even a one‑byte discrepancy changes the result.

Therefore, we use a plane layout that differs from the usual VP9 video‑encoding arrangement.

RGB
  R → VP9 plane 0
  G → VP9 plane 1
  B → VP9 plane 2

RGBA
  RGB → color stream
  A → plane 0 of a separate alpha stream

Because it stores directly in an 8‑bit full‑resolution I444 format, no conversion from RGB to YUV or subsampling is performed. The alpha streams of planes 1 and 2 in the RGBA data are fixed to zero, and this is also verified during decoding.

Even for pixels whose opacity is zero, the RGB values are not necessarily zero. During rendering or image processing, RGB values can remain after the pixel becomes transparent. Visual Store also preserves those RGB values, so it does not discard the RGB data beneath an alpha of zero.

How losslessness is verified

We do not consider it sufficient simply to specify lossless on the VP9 side.

When round‑tripping an image, we at least verify the following:

Width and height
RGB or RGBA pixel samples
alpha
RGB values of pixels where alpha = 0
Scanline that includes filtering
Content and order of non‑IDAT chunks

VP9 frames retain the pixel samples, but they do not preserve the PNG container information. Therefore, we store the original PNG’s IHDR chunk, the received non‑IDAT chunks and their positions, and the original filter for each scanline in a separate descriptor called png_reconstruction.

During decoding, we use that descriptor to reconstruct the PNG’s scanlines, verify the SHA‑256 hashes of the pixels, scanlines, and non‑IDAT data, and then assemble the PNG. The assembled PNG also passes the strict PNG validation that was applied to the input.

Consequently, the PNG obtained after packing may not be an exact byte‑for‑byte copy of the original file; the zlib compression results and the boundaries of IDAT chunks can differ. If the exact byte sequence of the original file is required, it is retained separately using the previously mentioned --keep-source.

Whether true temporal compression is actually used

Simply using VP9 does not guarantee that temporal compression is being applied; the images might just be placed in a video container.

Therefore, in our tests we feed multiple changing frames sequentially to the same encoder and verify that the first frame is a keyframe and that subsequent frames contain non‑key packets. Furthermore, we confirm that feeding only those non‑key packets to a new decoder causes decoding to fail, whereas decoding them sequentially after the preceding frame restores the original pixels.

In other words, the subsequent frames truly depend on the previous decoder state. This confirms that temporal compression is being employed.

We also have an all‑intra encoder for testing, which treats every frame as a keyframe. With the same input, we have confirmed that ordinary VP9, which uses temporal prediction, yields a smaller result.

Frame numbers and get‑frame

The image sequence includes run, stream, and frame_no.

run    = omoikane-test-001
stream = firefox
frame  = 17

When run and stream are specified, frame numbers are assigned in registration order starting at zero. These numbers remain unchanged even after packing. They are user‑facing identifiers, distinct from the physical frame_index within the VP9 segment.

Even after packing, an image can be extracted as a single PNG using its reference ID or frame number.

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 selects one target from the index of (run, stream, frame_no) and does not read unrelated segments. It then decodes from the selected segment’s keyframe—or from the recorded decode start position—up to the target frame. Because inter frames require the previous state, the target frame cannot be read in isolation; however, the system is not set up to decode the entire run from the beginning.

How much smaller it became in practice

In the benchmark included in the repository, we used 32 RGB images of size 640 × 360 that comprised a static application screen, a scrolling line, and a moving cursor. The test environment was an Apple M2 MacBook Air with 8 cores, 24 GB of RAM, macOS 26.5.1, Rust 1.95.0, libvpx 1.16.0, a release build, a single codec thread, and a segment length of 32.

The results were as follows.

RepresentationSize
Input PNG sequence22,135,456 bytes
Distinct blob after PNG recompression89,272 bytes
Inter‑frame VP9 package30,558 bytes
All‑intra VP9 package62,858 bytes

Here, the inter‑frame VP9 package includes the VP9 color container, the PNG reconstruction descriptor, the codec descriptor, and the additional SQLite index size incurred by packing. The VP9 color container alone is 9,378 bytes, but we have summed the relevant overhead to compare the actual storage representation.

Looking at the reduction from 22 MB of input PNGs down to just 30 KB may seem dramatic. However, the original PNGs are uncompressed fixtures, so even recompressing them alone shrinks them to 89 KB.

If you want to see the effect of temporal compression in Visual Store, you should first look at 89,272 bytes → 30,558 bytes. In this fixture, the inter‑frame VP9 representation is 65.8 % smaller than the PNG after recompression and 51.4 % smaller than the all‑intra VP9 representation.

This is just one example that depends on the image content and the environment; it cannot be treated as a typical compression ratio.

Retrieval speed

Using the same benchmark, we retrieved frame 17 from the packed data 20 times from a warm cache. After three warm‑up runs, the median latency was 25.676 ms, and the 95th percentile was 30.729 ms.

The decoded range in this case was frames 0 through 17 within a single segment. To fetch an inter frame that lies in the middle of a segment, the preceding packets must be read to establish the reference state. Using a longer segment could improve compression efficiency, but it also enlarges the amount of data that must be read when accessing an intermediate frame. The default segment length of about 32 frames was chosen to balance these considerations.

Original PNGs are not deleted immediately after packing

Even if packing succeeds, the original PNG representation is not removed right away.

The VP9 representation is activated only after the candidate encoding and the round‑trip verification have passed, but keeping the original PNG allows rollback in case any problem arises. Even if the process terminates midway through packing, only the unfinished candidate remains unused, and the original active representation is left untouched.

When deletion is performed, we first check as follows:

vstore --store "$PWD/.visual-store" prune --dry-run

Only when prune --apply is explicitly invoked do we delete the PNG that has already been verified and is no longer needed. Deletion proceeds by acquiring an exclusive lock on the store and recording a state called retained → pending → deleted. Even if the operation is interrupted, the next run can resume by checking that state. The shared blob is counted only once, not per image, and the input files, source, export, and VP9 segments are not deleted.

In the benchmark, the physical object after packing shrank from 99,059 bytes to 9,787 bytes, reclaiming 89,272 bytes. This is also a measurement taken with this fixture.

Migration from v1 to v2

The v1 store, which did not include temporal compression, can still be opened for reading without any changes. Opening an older store does not automatically modify it.

To perform writes or pack operations, you must first back up the entire system and then explicitly carry out a migration.

vstore --store PATH migrate --to 2

During the migration, an SQLite backup and a journal are created, and the database, manifest, and migration history are updated sequentially. If the process stops midway, normal operation halts at E_MIGRATION_INCOMPLETE; you can either proceed from --resume or revert to the v1 backup using --restore.

After the migration, the store UUID, image UUID, registration order, metadata, hashes, operation IDs, and other attributes are retained. This migration is intended to prevent logical references from being broken by changes to the storage format.

Verify Integrity First

Since this is an image storage system, we first confirmed that it does not become corrupted before considering compression ratios.

In testing, we examined concurrent execution of put, put, pack, and get from multiple processes, operation‑ID retries, premature termination, corruption of VP9 segments and reconstruction descriptors, interruption of the migration from v1 to v2, and interruptions at each stage of prune.

Packing first writes the candidate to a file, validates it, and then switches the active representation within a brief SQLite transaction. Candidates that stop before commit remain unreferenced, and after commit they are treated as immutable segments.

In verify, we verify not only SQLite consistency but also the blob size and SHA‑256, the segment, frame mapping, PNG reconstruction, pixel data, scanlines, non‑IDAT sections, and the final PNG itself. The image data itself is not output to JSON.

We also test v1 fixtures, macOS and Linux platforms, builds without VP9 support, and inputs that exceed resource limits. In builds without VP9 support, PNGs and metadata can be handled, but acquiring VP9 segments and performing full verification is handled by E_CODEC_UNAVAILABLE.

Summary

The reason we created the Visual Store was not merely because we wanted to compress and store debug images.

We wanted to separate the act of storing images from showing images to AI models.

In put, the PNG is validated and saved locally, and only a small JSON and a visual:// reference are returned. When a batch of images is assembled, pack converts only the necessary ones into time‑compressed VP9 segments. Only when a specific image needs to be inspected do we use get or get-frame, at which point the image is finally handed off for display.

The VP9 storage format does not use the usual video‑processing conversion from RGB to YUV; instead, it performs lossless storage using the separate RGB planes and an alpha stream. The original PNG’s container information is also preserved separately in the reconstruction descriptor, allowing verification not only of the image samples but also of the PNG itself.

In our measurements, 32 GUI images of size 640 × 360 bytes—89,272 bytes after recompressing the PNGs—were reduced to 30,558 bytes as a complete inter‑frame VP9 stream. However, this figure reflects a single fixture and does not indicate the amount of image‑token reduction achieved by the model.

The Visual Store is neither a tool for removing images that have already been displayed from a conversation history nor a tool for directly manipulating sessions inside Codex. It is a local storage system that keeps images ready for display only when needed.