Video streaming
HLS and DASH end to end — package a video once as CMAF, serve the package behind your authorization with media.access.streamRoute(), and play it with useMediaStream.
Streaming a video takes three pieces:
- a transformer that packages the upload into segments and manifests;
- a route that serves the package to whoever may watch it;
- a player in the browser.
The library ships all three.
Package it
import { authorizers, defineConfig, transformers } from '@adonis-agora/media'
export default defineConfig({
collections: [
{
name: 'lessons',
acceptsMimeTypes: ['video/*'],
access: { read: authorizers.authenticated() },
transformers: [transformers.video()],
},
],
})Run it after the upload. The library never runs a heavy transform on its own: see Running a transform.
Serve it
import media from '@adonis-agora/media/services/main'
const serveStream = media.access.streamRoute()
router.get('/media/:id/stream/:conversion/*', serveStream)Play it
async show(ctx: HttpContext) {
const lesson = await Lesson.findOrFail(ctx.params.id)
return ctx.inertia.render('lessons/show', {
lesson,
video: await media.access.stream(ctx, lesson.videoMediaId),
})
}import { useMediaStream } from '@adonis-agora/media-react'
const ref = useRef<HTMLVideoElement>(null)
useMediaStream(ref, video, { hls: () => import('hls.js'), dash: () => import('dashjs') })
return <video ref={ref} controls playsInline />See Video player for the hook.
HLS, DASH, or both
transformers.video() writes both by default, for the price of one. Its segments are CMAF
(fragmented MP4), the format HLS and DASH share, so one copy of the video is described by an HLS
master playlist (index.m3u8) and by a DASH manifest (manifest.mpd).
You don't choose a format per browser on the server. The player does, on the client
(useMediaStream): Safari and iPhones play HLS natively, everything else
uses hls.js, and DASH is there for dash.js and for DRM setups that need it.
| You need | Configure |
|---|---|
| Both formats (default) | transformers.video() |
| Only one | transformers.video({ formats: ['hls'] }) or ['dash'] |
| Players older than fMP4 HLS (iOS 9, some TVs) | transformers.video({ segments: 'ts', formats: ['hls'] }) |
| An adaptive-bitrate ladder | transformers.video({ engine: engines.ffmpeg({ ladder: engines.ladders.standard }) }) |
Without ffmpeg, the default engine (mediabunny, pure JS) remuxes the source as is: one rendition, no re-encode. See The video transformer for every option, the engines and the CMAF/TS trade-off.
The conversion's meta records what was written: hlsEntry and dashManifest (the manifests),
segments, durationSeconds, width, height, videoCodec, audioCodec and segmentCount.
The stream route
media.access.streamRoute(options?) returns an AdonisJS handler for
/media/:id/stream/:conversion/*. The URL mirrors the package, so a manifest's relative
references (media_0.m3u8, chunk-0-00001.m4s) resolve back to the same route. Nothing has to be
rewritten to point at your app.
For every request, it:
- Loads the record and checks the file. The file must be one the transformer wrote (the conversion's persisted file list). Anything else,
../included, gets404, so no caller input reaches the disk as a path. - Authorizes the read with
options.authorizeor, failing that, the collection'saccess.read, exactly like the delivery route:401for an anonymous request,403for a stranger. A valid package signature skips this (next section). - Answers a manifest (
.m3u8,.mpd) as text, with its content type. - Answers a segment per
delivery.mode, or the route'smode:signed: a redirect to a short-lived URL on the storage, minted for that request, so a long video never outlives it;public: a redirect to the object;proxy: the bytes, streamed through the app.
| Option | Default | |
|---|---|---|
authorize | the collection's access.read | Who may watch. |
mode | delivery.mode | How segments reach the player. |
signedTtlSeconds | delivery.signedTtlSeconds | Lifetime of a segment's redirect URL. |
cacheControl | 'private, max-age=60' | For manifests and redirects. |
segmentCacheControl | 'private, max-age=86400' | For segments streamed in proxy mode. A segment never changes. |
expiresIn | see below | Lifetime of the package signature. |
media.access.serveStream(ctx, record, conversion, file, options) is the same, for a controller
that finds the record its own way.
Session or signature
A player fetches a manifest, then dozens of segments. Each of those requests must be authorized, and there are two ways to do it:
- By session (no
delivery.signing). The browser sends its cookies with every request, so each segment goes throughauthorize. hls.js and dash.js send same-origin cookies by default. This is the simplest setup. - By signature (
delivery.signingconfigured).media.library.stream()signs the manifest URLs for the whole package: the HMAC covers the record and the conversion, not a single file. The route serves a signed request without asking the app, and adds the same?exp=&sig=to every reference in the manifests it answers. Segment requests then carry the authorization along, with no session and no database check per segment, and keep working even from another origin. A manifest requested by session gets a fresh signature in the same way.
A signature lives for expiresIn, which defaults to delivery.signing.expiresIn. It is never shorter than the video plus an hour, so a viewer doesn't lose access halfway through a long lesson.
Redirects to storage and CORS
hls.js and dash.js fetch segments with XHR/fetch. When the route redirects a segment to a
storage host (signed/public mode), that host must allow your app's origin
(Access-Control-Allow-Origin on the bucket). In proxy mode, everything stays same-origin.
media.library.stream() and media.access.stream()
const video = await media.access.stream(ctx, id) // authorizes, like media.access.image()
const video = await media.library.stream(id) // doesn't: you already did
// {
// src: '/media/<id>/stream/video/index.m3u8?exp=…&sig=…',
// format: 'hls',
// sources: [
// { src: '…/index.m3u8?…', format: 'hls', type: 'application/vnd.apple.mpegurl', conversion: 'video' },
// { src: '…/manifest.mpd?…', format: 'dash', type: 'application/dash+xml', conversion: 'video' },
// ],
// width: 1920, height: 1080, durationSeconds: 754.2,
// }- Which conversions count: every conversion that carries a manifest. HLS sources come first, because iPhones play HLS natively. With several video conversions (say
lessonandlesson_ts),conversions: ['lesson_ts', 'lesson']restricts them and sets their order exactly, conversion by conversion; within one conversion, its HLS master still comes before its MPD. - Not packaged yet:
stream()throwsTransformNotReadyErroruntil a package exists. The route answers that error with404. - Where the URLs point: at
delivery.streamPath(default/media/:id/stream/:conversion). Change it if you mount the route elsewhere:
defineConfig({ delivery: { streamPath: '/videos/:id/:conversion' } })
// router.get('/videos/:id/:conversion/*', media.access.streamRoute())Lower level: HlsDeliveryHandler
HlsDeliveryHandler is the earlier, framework-agnostic read path for the HLS side of a
package. It rewrites every playlist reference to URLs you build, with segments presigned inline,
and leaves authorization to your route. It is still exported for apps that need a custom URL
scheme. See Serving HLS by hand. For anything
new, prefer streamRoute(): it covers DASH, authorizes for you, and never hands out a segment URL
that expires mid-video.
Transformers
Pluggable content transformations — turn a stored media into derived artifacts (an HLS or DASH package, an extracted audio track) or pure metadata (a probe), persisted as named conversions. Ships with a video transformer (CMAF or TS segments, HLS and DASH manifests), a metadata probe and an image placeholder.
Attachments
Column attachments — the adonis-attachment style, where a file (and its image variants) lives directly on a model column as a JSON value, over the same Drive disks and image processor as the media library.