Skip to main content

Media

Media assets live in their own table (MediaAsset) outside the content tree. Entries reference assets by ID from media fields, link fields with mode: media, or richtext embeddedAsset nodes.

Storage​

Default backend is Supabase Storage. The provider is abstracted via src/lib/media/storage-provider.ts. Two providers ship today: supabase and local (filesystem-backed dev). Other backends (S3 / R2 / GCS) require writing a provider against the interface — not just a config change.

Configure via env:

STORAGE_PROVIDER=supabase # or `local` for filesystem-backed dev
SUPABASE_URL=https://….supabase.co # project URL, no trailing path
SUPABASE_SERVICE_KEY=… # service-role key (NOT the anon key)
SUPABASE_STORAGE_BUCKET=krios-media # auto-created (public) on first upload if missing

The bucket is auto-created (public) on the first upload if it doesn't already exist — no manual provisioning step. A single bucket is shared across projects; object keys are namespaced by projectId. If you'd rather pre-create it (e.g. to set a non-public policy), create it in the Supabase dashboard before the first upload and the auto-create becomes a no-op.

Asset shape​

{
id: string;
kind: "image" | "video" | "document" | "audio" | "other";
filename: string;
mimeType: string;
fileSize: number;
storageKey: string; // path within the bucket
width?: number;
height?: number; // images / video
duration?: number; // video / audio (seconds)
focalPointX?: number; // 0..1, for crop-aware image transforms
focalPointY?: number;
tags: string[];
folderId?: string;
isPublic: boolean;
}

Locale overlay​

MediaAssetLocale rows hold (altText, title, description, overrideStorageKey?) per locale, and both delivery surfaces expose the locale-resolved overlay:

  • GraphQL — MediaAsset.altText / title / description resolve against the request locale: the containing entry's locale for an embedded media field, the link's parent locale for a link asset, or the locale arg on the top-level asset(id, locale) query. No overlay row for that locale → null.
  • REST — media/{assetId}?locale= merges the overlay into the asset payload.

This is the DRY source of alt text — read it off the asset rather than duplicating an …Alt field per usage. Also useful for serving locale-specific variants (e.g. country-specific imagery).

Writing the overlay​

Set the overlay with an upsert on the management API — the MediaAssetLocale row is created on first write, so you don't pre-create it:

PUT /api/v1/projects/{slug}/media/{assetId}/locales/{locale}
Authorization: Bearer <management-key> # requires the `update` permission
Content-Type: application/json

{ "altText": "Red bicycle leaning against a brick wall" }
  • {locale} is a path segment (e.g. en-US) and must be registered in the tenant locale registry — an unknown code returns 404. It's used verbatim as part of the unique (assetId, locale) key.
  • Body fields are all optional and nullable: altText (≤2000), title (≤500), description (≤5000), overrideStorageKey (1–1024). It's a field-wise merge — send only what you want to change, pass null to clear, omit to leave untouched.
  • An empty body returns 422 no_update_fields. Success returns { data: <MediaAssetLocale> } and audit-logs a MEDIA_UPDATED event.

So the full alt-off-the-asset flow is: POST /media/upload → PUT …/media/{id}/locales/{locale} per locale → read back via delivery GraphQL (MediaAsset.altText) or REST (?locale=). The upload CLI has no --alt flag; alt is a separate write.

Upload​

REST:

POST /api/v1/projects/{slug}/media/upload
Authorization: Bearer …
Content-Type: multipart/form-data

files=@path/to/image.jpg

The form field is files (plural) and accepts up to 25 files per request.

CLI:

krios media upload ./logo.png --tags logo,brand --folder folder_id

Validation:

  • MIME magic-byte check (not just the Content-Type header).
  • The upload endpoint is not tied to any content-type field. Size and type limits come from Project.settings.media — maxFileSize and an allowedMimeTypes MIME-type allow-list (not file extensions).
  • When those aren't set, env fallbacks apply: MAX_FILE_SIZE_MB (default 100 MB) and ALLOWED_IMAGE_TYPES / ALLOWED_VIDEO_TYPES / ALLOWED_DOCUMENT_TYPES.

Image transforms​

Transforms are requested through arguments on the GraphQL asset url(...) field, not via URL query params:

asset(id: "…") {
url(width: 800, height: 600, format: webp, quality: 80)
}
  • width / height — target dimensions
  • format — re-encode to webp | avif | jpeg | png
  • quality — encode quality

Not implemented: there is no fit (crop-strategy) argument, and focal-point cropping is not applied — focalPointX/Y are stored and exposed on the asset but not consumed for cropping. On the Supabase provider the format argument is currently dropped.

Video​

Krios stores uploaded video files (MP4, WebM). Captions, posters, and durations live on the asset. External video providers (YouTube, Vimeo, Mux) are V1.5 — for now, treat video as a regular media asset.

Folders​

Media folders form a tree (MediaFolder table) with materialized paths. UI grouping only — paths in the bucket are flat (storageKey is a uuid).

Where-used + safety on delete​

GET /api/v1/projects/{slug}/media/{assetId}/references

Returns every entry that references the asset (via media field, embedded asset, or link.media mode). The same data drives the safety gate on delete:

DELETE /api/v1/projects/{slug}/media/{assetId}

Returns 409 with the reference list when the asset is in use.

Best practices​

  • Record focal points on hero images. focalPointX/Y are stored and exposed on the asset for consumers to use; note that Krios's own transforms do not yet crop around the focal point.
  • Tag aggressively. Search / filtering in the picker is keyed on tags; a "homepage" tag finds every asset on the home page in one click.
  • Use folders for organization, not access control. Folders don't gate read permissions — that's an API-key concern.