Dicey Table

Cover art and screenshots

A mod's discovery presence — its card in browse listings and its /games/<slug> details page — uses a square cover image, a matching thumbnail, and up to 12 landscape screenshots. All of it lives in the reserved media/ folder (packages/shared/src/modMedia.ts), a convention with real enforcement behind it, not just a naming suggestion.

Target sizes and encoding#

Asset Target size Constant
Cover image 512 × 512 px (square) COVER_SIZE = 512
Cover thumbnail 256 × 256 px (square) COVER_THUMB_SIZE = 256
Screenshot 1280 × 720 px (landscape) SCREENSHOT_WIDTH = 1280, SCREENSHOT_HEIGHT = 720

All processed media is encoded WebP at quality 0.7 (MEDIA_WEBP_QUALITY = 0.7) by the editor's media pipeline.

Conventional paths#

media/cover.webp          ← mediaCoverPath()
media/cover-thumb.webp    ← mediaCoverThumbPath()
media/screenshot-1.webp   ← mediaScreenshotPath(1)
media/screenshot-2.webp   ← mediaScreenshotPath(2)
…

coverImage and each entry in screenshots[] are ordinary repo-path strings in the manifest — see Manifest reference § coverImage and § screenshots. Nothing forces the exact filenames above; what's enforced is that the path lives under media/ and is an image.

Three real rules, not just convention#

  1. Hidden from the editor's file explorer. TableEditModeShell.tsx filters media/ paths out of the project-files list (isMediaPath()) — it's a production folder for the media pipeline, not something you browse or hand-edit alongside your other assets.

  2. Image-only at scan time. The scanner's validateMediaImageAsset() runs on every file whose path starts with media/, whether or not it's separately listed in assets[] — cover/screenshot paths referenced only from coverImage/screenshots[] are hashed and validated exactly like a declared asset (collectUndeclaredMediaTargets() sweeps them in). Allowed extensions here are a 4-item subset of the general asset allowlist: .webp .png .jpg .jpeg. Anything else under media/ — including otherwise-valid asset types like .glb or .mp3 — is rejected:

    `Files under media/ must be images (${sorted list of .bmp/.gif/.jpg/.jpeg/.png/.webp}); got ${extension || "no extension"}.` (code media-unsupported-type)

    (PNG/JPEG are accepted alongside WebP so an author can commit a raw source image; the editor's own pipeline always writes WebP.)

  3. Path safety, checked before the image rule. coverImage/screenshots[].path must be safe relative paths that actually resolve under media/ — a value trying to point outside it fails first with:

    "coverImage and screenshots must be relative paths under the media/ folder." (code media-path-invalid)

The screenshot crop rectangle#

Each entry in screenshots[] may carry an optional crop: a normalized rectangle (x, y, w, h, each a fraction 0–1 of the source image) recording how the published screenshot was framed, so the editor can re-open the original and re-crop rather than forcing a re-upload.

{ "path": "media/screenshot-1.webp", "crop": { "x": 0, "y": 0.1, "w": 1, "h": 0.8 } }

Constraints: x, y ∈ [0, 1]; w, h ∈ (0, 1] (strictly greater than 0); and the rectangle must stay inside the source image — checked as x + w ≤ 1.0001 and y + h ≤ 1.0001 (the 0.0001 slack absorbs floating-point rounding from the crop UI, nothing more). Violating either axis produces the same message:

"Crop rectangle must stay within the image bounds (x+w and y+h ≤ 1)."

Worked example — a complete media declaration#

"coverImage": "media/cover.webp",
"screenshots": [
  { "path": "media/screenshot-1.webp" },
  { "path": "media/screenshot-2.webp", "crop": { "x": 0.05, "y": 0, "w": 0.9, "h": 1 } }
]

media/cover-thumb.webp doesn't need its own manifest field — it's a derived thumbnail the editor generates alongside the full cover and serves from the same reserved folder.

See also#