Stack Specification
Section 06

Artwork data model

The format of an artwork bundle is the most performance‑sensitive contract in the product. Decisions made here cascade through render speed, sync size, and content pipeline complexity.

6.1 — Manifest

The manifest is a small JSON file describing the artwork, its files, and the parameters needed to load it.

{
  "id": "afro-caribbean-carnival-001",
  "slug": "afro-caribbean-carnival-001",
  "title": "Carnival Queen",
  "category": "Afro-Caribbean",
  "themes": ["carnival", "celebration", "portrait"],
  "difficulty": "medium",
  "regionCount": 850,
  "paletteSize": 18,
  "premium": true,
  "version": 3,
  "canvas": { "width": 2048, "height": 2048 },
  "files": {
    "regions": "regions.bin.br",
    "preview": "preview.webp",
    "thumbnail": "thumbnail.webp",
    "lineArt": "line_art.svg"
  },
  "artist": { "id": "art_034", "name": "Ama Owusu" },
  "checksum": "sha256:…"
}

6.2 — Region data (binary wire format · CLRX v1)

Region geometry is the heaviest part of the bundle. SVG path strings are ergonomic but expensive to parse — a 2,000‑region artwork pays that cost on every open. Ochre & Soul ships region geometry as a tokenised binary format with a fully specified layout. The on‑disk file is brotli‑compressed; the bytes described below are the decompressed payload.

File-level rules

  • All multi‑byte values are little‑endian.
  • All offsets are byte offsets from the start of their containing block.
  • Compression: brotli, quality 11, window 22, generic mode. The compressed file is named regions.bin.br.
  • Integrity: manifest.checksum is the SHA‑256 of the uncompressed payload, encoded as "sha256:<hex>". Verified on every load, not only at download.
  • Reserved fields are zero on write and ignored on read.

Layout

// Header (24 bytes)
u32  magic         = 0x434C5258              // "CLRX"
u16  formatVersion = 1                        // bump on any breaking change
u16  flags         = 0                        // reserved
u32  regionCount                              // 1 .. 65535
u16  paletteSize                              // 1 .. 256
u16  reserved      = 0
u32  canvasW                                  // px, > 0
u32  canvasH                                  // px, > 0

// Palette[paletteSize] (8 bytes per entry)
u8   colourId                                 // 1-based, unique
u8   r, g, b                                  // 0..255
u8   reserved      = 0
u16  nameKey                                  // index into manifest.paletteNames[]
u8   reserved      = 0

// RegionIndex[regionCount] (32 bytes per entry, fixed)
u32  byteOffset                               // offset into RegionData block
u16  colourId                                 // must exist in Palette
u16  verbCount                                // 1 .. 16384
f32  minX, minY, maxX, maxY                   // bounds in canvas coords
f32  labelX, labelY                           // label anchor in canvas coords

// RegionData (variable; one entry per region, in index order)
u8[verbCount]  verbs                          // see table below
f32[coordCount] coords                        // sum of args per verb

Verb encoding

CodeVerbf32 argsSemantics
0MoveTo2x, y — must be the first verb of any sub‑path
1LineTo2x, y
2CubicTo6cx1, cy1, cx2, cy2, x, y
3QuadTo4cx1, cy1, x, y
4Close0Closes the current sub‑path

Constraints & validation

  • regionCount ≤ 65535. verbCount per region ≤ 16384.
  • Every region’s first verb must be MoveTo; the last verb must be Close.
  • All coordinates must lie in [0, canvasW] × [0, canvasH] (validator may allow ±0.5 px tolerance).
  • Every region’s colourId must appear in the palette.
  • Bounds must enclose every coordinate in the region (validator computes and compares; mismatch is a publish error).
  • labelX, labelY must lie inside the region’s geometry; the validator uses a point‑in‑polygon test on a flattened path.
  • Decoded payload size must not exceed 32 MB. Larger artworks are rejected at publish time.

Reference parser pseudocode

function loadRegions(bytes):
  header   = read 24 bytes
  assert header.magic == 0x434C5258
  assert header.formatVersion in supportedVersions  // see §6.6
  palette  = read paletteSize * 8 bytes
  index    = read regionCount * 32 bytes
  dataBase = current offset
  for i in 0 .. regionCount-1:
    seek dataBase + index[i].byteOffset
    verbs  = read index[i].verbCount bytes
    args   = sum of argCount[v] for v in verbs
    coords = read args * 4 bytes (f32)
    yield Region(verbs, coords, index[i].bounds, index[i].label, index[i].colourId)

Why binary, not SVG strings

Parsing 2,000 SVG path strings on artwork open consumes hundreds of milliseconds on mid‑range Android. CLRX is parsed in a single allocation pass with no string scanning. The SVG line‑art file is still shipped for reference (artist tooling, web preview), but the runtime never parses it.

6.3 — Region indexing

Regions are addressed by their position in the regions array (a uint16 index), not by string ID. This index is the same identifier used in completed‑region bitsets and in sync payloads. String IDs like "r_0001" are not used.

6.4 — Palette names & localisation

Palette display names (“Deep brown”, “Saffron”) live in the manifest under paletteNames as an array indexed by nameKey. The manifest itself remains in the canonical English form; localised palette names are served from a palette_translations PostgreSQL table keyed by (artwork_id, colour_id, locale). This avoids re‑bundling artworks for translation.

6.5 — Versioning

Every bundle carries a version integer and a checksum. When the catalogue API reports a newer version than the locally cached bundle, the app downloads the new bundle and runs a region‑mapping step against the user’s progress. Mapping rules:

  • If regionCount is unchanged and bounds overlap by ≥ 95% for every region, treat the upgrade as compatible and preserve progress as‑is.
  • Otherwise mark the local progress as “needs review” and prompt the user to keep playing the old version (kept until next launch) or restart on the new version.

6.6 — Bundle lifecycle

Download, integrity, cache, eviction, and app compatibility rules. These are the rules the client must follow on every artwork open — they are not optional polish.

6.6.1 — On‑disk layout

<app-support>/artworks/
  <slug>/
    v<version>/
      manifest.json
      regions.bin.br
      preview.webp
      thumbnail.webp
    current → v<version>            // symlink or pointer file
  _tmp/
    <download-id>.part               // in-flight downloads

6.6.2 — Download

  1. Client requests signed URLs (premium) or reads public CDN URLs (free).
  2. Bytes are written to _tmp/<download-id>.part. HTTP Range requests are used to resume after network failure; the client tracks bytes received and retries with exponential backoff (1 s → 32 s, max 6 attempts).
  3. On completion, the client decompresses regions.bin.br into memory, computes SHA‑256, and compares against manifest.checksum.
  4. If the checksum matches, the temp directory is atomically renamed to <slug>/v<version>/ and the current pointer is updated. If it does not match, the partial files are deleted and the download retries once. A second failure surfaces a user‑facing error and is logged to Sentry.
  5. Signed URLs expire after 15 minutes; if the download is still in progress the client re‑calls get-artwork-download-url and resumes with the new URL.

6.6.3 — On‑load verification

Every artwork open verifies integrity before rendering. To avoid recomputing SHA‑256 on every open, the client persists a verified_at timestamp; verification is only re‑run if the file’s mtime changes or the timestamp is older than 30 days. A failed verification on load triggers an automatic re‑download of the bundle.

6.6.4 — App ↔ bundle compatibility

The Flutter client declares a supportedBundleFormat range (currently { min: 1, max: 1 }). On every artwork open:

  • If header.formatVersion > max → block and prompt the user to update the app.
  • If header.formatVersion < min → silently re‑download the bundle (server always serves a current‑format build).
  • If header.formatVersion is in range → load as normal.

Bumping formatVersion is a breaking change; the server holds at most two adjacent format versions live at once.

6.6.5 — Cache & eviction

  • Cache lives under app‑support so it persists across launches but is removable by the OS under storage pressure.
  • Soft cap 500 MB; hard cap 1 GB. When approaching the soft cap the client evicts least‑recently‑opened artworks first.
  • In‑progress artworks (any region completed and progress not synced) are never evicted automatically.
  • On a successful version upgrade, the previous v<n-1>/ directory is kept until the next app launch then deleted.
  • If verified_at is older than 30 days and the cache is under pressure, the artwork is evicted rather than re‑verified.

6.6.6 — Corrupted & orphaned files

On every launch, the client runs a cheap reconciliation pass: any _tmp/*.part file older than 24 hours is deleted; any <slug>/v*/ directory not pointed to by current and not in the active list is deleted; any artwork whose current pointer is dangling is removed from the local catalogue and re‑surfaced for download.