---
title: "Images and video"
description: "Turn uploads into image sizes, HLS video and audio in a background job, written back through files with location data stripped by default."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "media"
canonical: "https://mainsdk.dev/docs/interfaces/media"
updated: "2026-10-28"
---

# Images and video

> Turn uploads into image sizes, HLS video and audio in a background job, written back through files with location data stripped by default.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/media · Updated: 2026-10-28

`media` turns uploads into AVIF and WebP images and HLS video and audio in a durable job, with location data stripped. Reach for it after a user uploads through [`files`](https://mainsdk.dev/docs/interfaces/files).

> **Note:** Built in 0.4 and covered by tests on the real workflow engine. The `local` driver keeps the job and file flow working offline but does not transcode. The `getqr`, `mux` and `cloudinary` endpoints are tested against in-repo fakes, not live accounts.

## At a glance

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/media.md
- Category: Files, sharing and media

Calls:

- `media.image(file, { widths?, formats? })`
- `media.video(file, { renditions? })`
- `media.audio(file, { bitrates? })`
- `media.get(jobId)`
- `media.cancel(jobId, { reason })`
- `media.url(output, { for })`
- `media.playlist(job, output, { for })`

In main.yaml:

```yaml
media:
  driver: getqr
  image: { widths: [320, 640, 1280], formats: [avif, webp], max-pixels: 40MP }
  video: { renditions: [360p, 720p], max-duration: 10m, max-size: 2GB }
```

Guarantees, whatever the driver:

- Never processed in the request path; every call is a resumable workflow
- Every output goes through files, owned by the source's owner, in the tenant's region
- EXIF, XMP, GPS and container metadata stripped from every output; location can't be kept
- Type, size, duration and pixel limits checked before any transform
- ffmpeg runs only as a declared program through system.run


## Drivers

Pick one in main.yaml with `media.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| getqr.id | `getqr` | yes |  |
| Mux | `mux` |  |  |
| Cloudinary | `cloudinary` |  |  |
| ffmpeg (development | `ffmpeg` |  |  |
| Local placeholder driver (right structure | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-MEDIA-0007` | check | `media.keep` lists `gps`, `location` or an unknown metadata name (start fails; check finding) |
| `MAIN-MEDIA-0010` | check | The `ffmpeg` driver in production |
| `MAIN-MEDIA-0011` | check | An image or video library (`sharp`, `jimp`, `fluent-ffmpeg`, `@ffmpeg/ffmpeg`, `ffmpeg-static`) or an `ffmpeg` program run outside a driver |
| `MAIN-MEDIA-0012` | check | `delivery: provider` on `mux` or `cloudinary`: outputs served by the provider, outside `files` (named gap) |
| `MAIN-MEDIA-0013` | check | A media kind used with no limits, or a limit above its ceiling: `max-duration` over 4h, `max-size` over 10GB, `max-pixels` over 100MP |
| `MAIN-MEDIA-0001` | runtime | The source file isn't `ready`, doesn't exist in the current tenant, or the caller may not read it (404) |
| `MAIN-MEDIA-0002` | runtime | The source is over a limit: duration, size or pixels; the job failed in the probe step, nothing was transformed (422) |
| `MAIN-MEDIA-0003` | runtime | The source's magic bytes don't match a supported type, or don't match its declared type (415) |
| `MAIN-MEDIA-0004` | runtime | The driver doesn't support this kind or format, such as an image on `mux` (501) |
| `MAIN-MEDIA-0005` | runtime | A transform step failed after its last retry: the provider failed, or `ffmpeg` exited non-zero; the job is `failed` |
| `MAIN-MEDIA-0006` | runtime | No job with that id in the current tenant (404) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| media@1 contract | 1 | 0.4 | `media#contract` |
| Processing as a workflow | 1 | 0.4 | `media#workflow` |
| Metadata stripped by default | 1 | 0.4 | `media#strip` |
| Video and audio to HLS with thumbnails | 1 | 0.4 | `media#video-audio` |
| Limits before work | 1 | 0.4 | `media#limits` |
| ffmpeg development driver | 1 | 0.4 | `media#driver-ffmpeg` |
| getqr.id driver | 1 | 0.4 | `media#driver-getqr` |
| Mux and Cloudinary drivers | 1 | 0.4 | `media#drivers-mux-cloudinary` |
| Moderation hold | 1 | 0.4 | `media#hold` |
| Tenants | 1 | 0.4 | `media#tenancy-access` |
| Media checks | 1 | 0.4 | `media#checks` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
media:
  driver: getqr
```

```ts title="app/photos/thumb.ts"
import { media } from "mainsdk/media";
import type { FileRef } from "mainsdk/files";

export async function thumbnails(photo: FileRef) {
  const job = await media.image(photo, { widths: [320, 640], formats: ["avif", "webp"] });
  return job.id;   // read it with media.get(job.id)
}
```

The job is `queued` when the call returns. Its outputs are files, owned by the source's owner, in the tenant's region.

## Configuration

```yaml title="main.yaml"
media:
  driver: getqr                    # getqr (default) | mux | cloudinary | ffmpeg (development)
  image: { widths: [320, 640, 1280, 2048], formats: [avif, webp], quality: 70, max-pixels: 40MP, max-size: 25MB }
  video: { renditions: [360p, 720p, 1080p], segment: 6s, max-duration: 10m, max-size: 2GB }
  audio: { bitrates: [64k, 128k], max-duration: 60m, max-size: 200MB }
  keep: []                         # metadata kept: icc, orientation-tag; never gps or location
```

| Key | What it does |
| --- | --- |
| `driver` | getqr.id by default; `mux` serves video and audio, `cloudinary` images and video, `ffmpeg` is for development and runs only as a declared program |
| `image`, `video`, `audio` | Output sizes and formats, and the limits checked before any work |
| `keep` | `icc` and `orientation-tag` only. `gps` or `location` fails with `MAIN-MEDIA-0007` |

## What you get

Every call starts a durable workflow and returns a job, so a request never waits on a transcode. The calls:

```text
media.image(file, { widths?, formats?, quality?, fit?, idempotencyKey? })   // MediaJob
media.video(file, { renditions?, thumbnails?, idempotencyKey? })
media.audio(file, { bitrates?, idempotencyKey? })
media.get(jobId)
media.cancel(jobId, { reason })                                             // deletes partial outputs
media.url(output, { for: user, expiresIn? })                                // a signed URL, as files.url
media.playlist(job, output, { for, expiresIn? })                            // an HLS playlist with each relative path replaced by a signed URL
```

A job has `state` (`queued`, `running`, `held`, `ready`, `failed`, `cancelled`), a safe `probe` and `outputs`; each output is `{ kind, format, width?, height?, bytes, file }`. The workflow runs probe, limit check, one transform per output, write, then ready, and resumes after a crash without redoing a finished output.

## Examples

### Transcode a proof video

```ts title="lib/proof-video.ts"
import { media } from "mainsdk/media";
import type { FileRef } from "mainsdk/files";

export async function prepareVideo(video: FileRef) {
  const job = await media.video(video, { renditions: ["360p", "720p"], thumbnails: true });
  return job.id;
}
```

### Serve a thumbnail

```ts title="app/api/photos/[id]/thumb/route.ts"
import { auth } from "mainsdk";
import { media } from "mainsdk/media";

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth.require(req);
  const { id } = await params;
  const job = await media.get(id);   // here id is the job id
  const thumb = job.outputs.find((o) => o.kind === "image" && o.width === 320);
  if (!thumb) return new Response("not ready", { status: 404 });
  return Response.json({ url: await media.url(thumb, { for: { id: session.user } }) });
}
```

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-MEDIA-0001` | The source is not `ready`, not in this tenant, or the caller may not read it | Wait for `files.complete`, or check the owner |
| `MAIN-MEDIA-0002` | The source is over a duration, size or pixel limit | Send a smaller file or raise the limit on purpose |
| `MAIN-MEDIA-0003` | The bytes are not a supported type, or not the declared type | Send a JPEG, PNG, WebP, AVIF, HEIC, GIF, MP4, MOV, WebM, MP3, AAC, M4A, WAV or Ogg file |
| `MAIN-MEDIA-0004` | The driver does not support this kind or format | Use a driver that does |
| `MAIN-MEDIA-0005` | A transform step failed after its last retry | Read `job.error`, then retry with the same idempotency key |
| `MAIN-MEDIA-0006` | No job with that id in this tenant | Use a job id from this tenant |

The spec also defines check codes `MAIN-MEDIA-0010` to `0013` (the `ffmpeg` driver in production, an image or video library used outside a driver, provider-served delivery, limits above their ceilings).

## Swapping drivers

```yaml title="main.yaml"
media:
  driver: mux   # was getqr
```

Your calls stay the same. A driver that cannot serve a kind fails with `MAIN-MEDIA-0004`, such as an image on `mux`. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

## Testing

`fakeMediaDriver()` and the fixtures `makeJpeg`, `makePng`, `makeMp4` and the like come from `mainsdk/testing`, and `runMediaConformance` checks a driver against the spec. See [Test your app](https://mainsdk.dev/docs/guides/testing).

## Security and privacy

- Every output carries no EXIF, XMP, GPS or container metadata; orientation is applied to the pixels first.
- Outputs are written through [`files`](https://mainsdk.dev/docs/interfaces/files), so owner scope, signed URLs, scanning and regions apply. `media` never serves the original.
- With `moderate.hold.media`, outputs stay `held` until screened.
- Deleting the source, erasing its owner or erasing the tenant deletes every output made from it.

## FAQ

**Which formats are supported?** Images: JPEG, PNG, WebP, AVIF, HEIC, GIF. Video: MP4, MOV, WebM. Audio: MP3, AAC, M4A, WAV, Ogg. Outputs are AVIF and WebP images, and HLS for video and audio. HEIC support is an open question in the spec.

## Next

- [File uploads](https://mainsdk.dev/docs/interfaces/files): where media comes from.
- [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share): share the result.
- [Privacy and data rights](https://mainsdk.dev/docs/interfaces/privacy): export or erase a person's media.
