File Format & Storage
How v2 handles file I/O across 10+ formats with declarative decision engines, how the three-key asset store preserves bit-exact pixels, and how the .gpex container packs a project for persistence and cloud upload.
FileService: Four-Stage Ingest Pipeline
FileService is the unified file I/O facade between business commands and the codec layer (browser APIs + Workers). A format router dispatches by MIME/extension to per-format handlers, and every decode follows the same four stages:
Stage 1 Header sniff handler.extractMetadata(file) — milliseconds;
physical size, color, bit depth, isMultiFrame
Stage 2 Global decision resolveIngestDecision(metadata) — the single
authoritative decision function
Stage 3 Physical decode handler.decode(file, meta, decision) — decision is
a read-only parameter; handlers never re-decide
Stage 4 Assembly authoritative colorIdentity injected into every page;
sourceBlob attached when required
The Ingest Decision Engine
resolveIngestDecision is a declarative rule engine: a 26-row matrix spanning format × bit depth (8/10/12/14/16/32) × detected source gamut, evaluated first-match-wins. Each rule binds an action payload:
export type DecodeChannel =
| 'image-bitmap' // browser-native zero-copy (8-bit, single frame, srgb/p3)
| 'wide-gamut-8' // 8-bit wide gamut: read back pixels, lift to f16
| 'vips' // lib-vips naked-pixel decode (16-bit TIFF/PNG, 32-bit float)
| 'vips-icc' // ICC engine path (CMYK)
| 'libraw' // camera RAW
| 'heic-to' // HEIC transcode proxy
| 'gifuct' // animated GIF per-frame
| 'vector' // SVG/EPS rasterize
| 'unsupported'; // fail-fast terminal row
export interface IngestDecision {
readonly colorIdentity: ColorIdentity; // gamut, trc, true bitDepth, dataFormat
readonly retainSourceBlob: boolean;
readonly skipContentBoundsScan: boolean; // only when the source is known alpha-free
readonly decodeChannel: DecodeChannel;
readonly applyOrientation: 'auto' | 'explicit' | 'none';
}
Two normative rules:
- Fail-fast on high precision. If a high-depth channel (
dataFormat: 'rgba16float') fails to decode, the pipeline throws — silently degrading to 8-bit is forbidden, because it would produce metadata claiming 16-bit truth with nodec:asset behind it. retainSourceBlob⟺ channel ≠image-bitmap. Only the verbatim browser-decodable lane needs no stored original; every derived-proxy channel must keep the source file for lossless revert and re-export.
Asset Storage: Three-Key Content Addressing
Assets are the single source of truth for pixel identity. Imported asset IDs anchor to source-file truth: assetId = sha256(sourceBlob) + "#" + pageIndex. Derived (baked/composited) assets hash their display blob with no page suffix. Anchoring to the source (not an 8-bit proxy) makes duplicate imports idempotent, makes Revert produce the identical hash, and lets multi-page files share one physical original.
Each imported asset can occupy up to three IndexedDB keys:
| Key | Contents | Size | Condition |
|---|---|---|---|
${id} |
Light record: geometry, ColorIdentity, 8-bit display bitmap | KB | always |
dec:${id} |
Naked f16/f32 pixel buffer (Uint16Array/Float32Array) |
~100–200 MB | high-depth assets only |
raw:${fileHash} |
Original encoded source file | MB–50 MB | retainSourceBlob only; shared across pages |
Deserialization defense: bulk reads and state hydration only ever touch the light record. Naked pixels enter memory exclusively through an explicit high-depth accessor when the render pipeline needs to build a GPU texture.
Write Ordering and GC
- Heavy before light. The
dec:buffer is written first; only after it lands does the light record claimdataFormat— a failed write degrades to a plain 8-bit record instead of a half-registered "phantom" high-depth asset. - Birth protection. New asset IDs enter a pending set before any disk write, so concurrent GC cannot orphan them during the multi-second window before state commit.
- Dual-track sweep. GC scans the in-memory pool (reference-counted owners + LRU + 5s grace period for undo) and both heavy key spaces, building a protected set from active IDs, pending IDs, and grace-period residents.
raw:keys survive as long as any page sharing that file hash is alive.
.gpex Project Container
The native save format is a ZIP bundle that snapshots the hydrated asset library — not a re-ingest of unknown files:
project.gpex (ZIP)
├── state.json frame/layer structure
├── assets-manifest.json per-asset colorIdentity, geometry, source class
├── assets/{assetId} 8-bit display bitmaps (all assets)
├── raw/{fileHash} original source files (deduplicated by hash)
└── dec/{assetId}.bin naked high-precision pixels (when present)
Unpacking is hydration, not re-ingest: size and color come exclusively from the manifest, each asset is classified by manifest entries (retained source / bake product / plain 8-bit), and original asset IDs are force-locked via precomputed hashes — never re-hashed. Legacy containers without a manifest have no meta at all; geometry falls back to probing the display blob itself (createImageBitmap), degrading safely to the 8-bit class. The core invariant: a pack/unpack round trip restores geometry and ColorIdentity 100% losslessly.
Export: Egest Decision Engine
resolveEgestDecision is the export-side mirror of ingest. It routes to one of three physical channels:
| Channel | Bit depth | Path |
|---|---|---|
canvas-8 |
8 | Un-premultiply + quantize → tagged OffscreenCanvas → ImageBitmap → browser/MozJPEG encode (sRGB / P3 only) |
raw-8 |
8 | Naked Uint8Array → LibVipsWorker native encode (all gamuts, no Canvas involved) |
raw-16 |
16 | Naked Uint16Array → LibVipsWorker native encode (full gamut, professional precision) |
Channel selection is a one-way derivation from requested bit depth and target gamut — 16-bit requests go to raw-16 when the container supports it; otherwise 8-bit canvas-safe gamuts go to canvas-8 and wide gamuts go straight to raw-8. The v1 "wide gamut must upscale to 16-bit" rule and its representability gates are abolished: an 8-bit Adobe RGB source can now be exported byte-faithfully without doubling file size or being clamped to P3.
Pass-Through Bypass
When a file is unmodified and the export parameters match exactly (no crop, no scale, no adjustments, same format and gamut, sourceBlob present), the original bytes are streamed out directly — zero GPU readback, zero generation loss, and full EXIF/ICC metadata preservation.
Format Capabilities (summary)
| Format | Channels | Max depth | Gamuts | ICC |
|---|---|---|---|---|
| PNG / TIFF | canvas-8, raw-8, raw-16 | 16 | srgb, p3, adobe-rgb, prophoto-rgb | yes |
| JPEG / WebP | canvas-8 | 8 | srgb, p3 | yes |
| AVIF | canvas-8 | 8 | srgb, p3 | no (encoder limitation) |
| BMP / GIF | canvas-8 | 8 | srgb | no |
| HEIC / RAW | decode-only | — | — | no |
Metadata Round-Trip
| Data | Behavior |
|---|---|
| ICC profiles | Parsed at header sniff, carried in the raw metadata layer, embedded on encode per the egest decision |
| EXIF (camera, capture, GPS, dates) | Extracted into a typed metadata model; original TIFF IFD kept for passthrough |
| XMP | Passed through as UTF-8 text |
| DPI | Multi-source resolution: EXIF → PNG pHYs → BMP header → TIFF tag, with provenance recorded |
| EXIF orientation | Responsibility assigned per format by the ingest decision (auto/explicit/none); naked buffers and display bitmaps are always rotated in lockstep |
Last updated: 2026-10-12