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:

  • GraphQLMediaAsset.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.
  • RESTmedia/{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/uploadPUT …/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.mediamaxFileSize 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.