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.checksumis 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
| Code | Verb | f32 args | Semantics |
|---|---|---|---|
0 | MoveTo | 2 | x, y — must be the first verb of any sub‑path |
1 | LineTo | 2 | x, y |
2 | CubicTo | 6 | cx1, cy1, cx2, cy2, x, y |
3 | QuadTo | 4 | cx1, cy1, x, y |
4 | Close | 0 | Closes the current sub‑path |
Constraints & validation
regionCount≤ 65535.verbCountper region ≤ 16384.- Every region’s first verb must be
MoveTo; the last verb must beClose. - All coordinates must lie in
[0, canvasW]×[0, canvasH](validator may allow ±0.5 px tolerance). - Every region’s
colourIdmust appear in the palette. - Bounds must enclose every coordinate in the region (validator computes and compares; mismatch is a publish error).
labelX, labelYmust 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
regionCountis 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
- Client requests signed URLs (premium) or reads public CDN URLs (free).
- Bytes are written to
_tmp/<download-id>.part. HTTPRangerequests are used to resume after network failure; the client tracks bytes received and retries with exponential backoff (1 s → 32 s, max 6 attempts). - On completion, the client decompresses
regions.bin.brinto memory, computes SHA‑256, and compares againstmanifest.checksum. - If the checksum matches, the temp directory is atomically renamed to
<slug>/v<version>/and thecurrentpointer 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. - Signed URLs expire after 15 minutes; if the download is still in progress the client re‑calls
get-artwork-download-urland 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.formatVersionis 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_atis 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.