Files
Signage/docs/architecture/decisions/0007-native-images.md
2026-08-28 16:41:54 -05:00

5.8 KiB
Raw Permalink Blame History

0007: A native image is content-addressed, archived once, and never baked

  • Status: Accepted
  • Date: 2026-08-27
  • Related: 0001, Ekklesia ADR 0030 (host-provided storage), ADR 0042 (streaming bodies)

Context

Native images are the only content Signage owns, and the one thing it must do well with no other module installed. A church's actual usage is an operator dragging twenty exported slides onto a page, some of them the same file they uploaded last week.

Decision

Identity is the content hash

An asset's identity is the SHA-256 of the uploaded bytes, and the database enforces it as unique. Uploading the same bytes twice returns the existing asset instead of storing a second copy, and two operators uploading the same file — even simultaneously — produce one asset and one set of stored objects: the loser of that race finds the winner's asset and reports success, because the image the operator uploaded is stored.

Object keys are scoped to the asset (assets/<asset-id>/…), and until that asset's row commits they are temporary: nothing references them, nothing can select them, and a failed ingest deletes every one it wrote. Scoping them to the asset rather than to a throwaway upload id is what makes a successful upload cost exactly one write of each object instead of two — the commit is the database row, not a second copy of 25 MiB.

One file per request, streamed, bounded

Uploads use bodyMode: "streamed" with a 25 MiB Signage limit, inside the platform's 32 MiB absolute ceiling. The unit is deliberately one file: a dashboard offering twenty images issues twenty individually bounded requests, each of which can fail cheaply, rather than one request whose failure wastes everything already sent. Nothing in Signage can express an aggregate upload.

Because a 413 can arrive after the handler starts in streamed mode, nothing is written anywhere until the whole request has been read and validated. An upload that is aborted, oversized, malformed, or unsupported has therefore written zero objects and zero rows. Once validation passes, the objects are written to the temporary asset-scoped keys above and the row commits last; any failure in between removes every object that attempt wrote. A partial write is never promoted to an asset.

Accepted, and refused

Accepted: still PNG, JPEG, and WebP. Refused: SVG, GIF and any animation, PDF, audio, video, multipage input, anything malformed, anything over 25 MiB, and anything whose decoded size exceeds 40 million pixels — the last because decoded pixels, not file size, are what a decompression bomb actually costs.

Format is decided by decoding the bytes, never by the filename or the declared content type.

Three stored objects, and what each is for

Object Purpose
Original Archived byte-for-byte, unmodified. It is the church's file; Signage is not entitled to rewrite it.
Display WebP Inside-fit within 1920×1080, quality 82. What a television actually renders.
Thumbnail WebP Inside-fit within 480×270, quality 72. What the library grid renders.

Both renditions are auto-oriented, never cropped, never upscaled, stripped of metadata, and flattened against black. The original's real dimensions, orientation, and useful metadata stay in PostgreSQL, where they are queryable without touching storage.

Presentation is resolved at render time, never baked

A 16:9 image (within a documented tolerance of 0.02) fills the frame on its own: no blurred duplicate, no pillarbox, no floating-card shadow. Anything else is shown whole with object-fit: contain over a blurred, solid, or black background, optionally darkened, with a foreground shadow.

None of that is written into a stored rendition. Changing a slot's background from blurred to black is a database update, not a re-encode, and the same rendition serves both. These controls apply to native images only — an external provider owns its rendering completely.

Keys are persisted; URLs are not

Signage stores logical object keys and asks the host for a fresh read URL when something is about to be delivered. A read URL is a bearer capability with an expiry in it; storing one in a table would be storing a credential that stops working. Signage never configures, names, or identifies a storage service.

Deletion is reference-aware

Deleting an asset referenced by a timeline slot, by a pinned item, or by a live static hold is refused with a sanitized code naming what refers to it. An asset with a null expiration is permanent; local age never invents one. Rendition identity is immutable — a rendition is replaced by uploading different bytes, which by definition produces a different asset.

Consequences

  • Twenty uploads are twenty independent, individually recoverable requests, and the dashboard stays responsive because none of them blocks another.
  • Re-uploading last week's file costs one hash and one row read.
  • A partially failed ingest leaves a temporary object that is removed, never an asset in a state playback could select.
  • Changing presentation is instant and lossless, because nothing was baked.

Alternatives

  • A generated UUID as identity. Rejected: it makes deduplication impossible and retries dangerous.
  • Trust the declared content type. Rejected: it is attacker-controlled and routinely wrong even when it is not.
  • Bake the background and shadow into the rendition. Rejected: every presentation change becomes a re-encode, and the archived original stops being the source of truth.
  • One multi-file upload request. Rejected: one bad byte wastes every byte already sent, and the platform's ceiling is per request for exactly that reason.