Launch OpenGPEX

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 no dec: 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 claim dataFormat — 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