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 / descriptionresolve against the request locale: the containing entry's locale for an embeddedmediafield, the link's parent locale for a link asset, or thelocalearg on the top-levelasset(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 returns404. 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, passnullto clear, omit to leave untouched. - An empty body returns
422 no_update_fields. Success returns{ data: <MediaAssetLocale> }and audit-logs aMEDIA_UPDATEDevent.
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-Typeheader). - The upload endpoint is not tied to any content-type field. Size and type limits come from
Project.settings.media—maxFileSizeand anallowedMimeTypesMIME-type allow-list (not file extensions). - When those aren't set, env fallbacks apply:
MAX_FILE_SIZE_MB(default 100 MB) andALLOWED_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 dimensionsformat— re-encode towebp | avif | jpeg | pngquality— 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/Yare 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.