cloud_queue
Better MediaDeveloper docs

Video Streaming

Adaptive bitrate video transcoding and streaming with HLS and DASH.

Raw video files are large, codec-incompatible across devices, and impossible to seek efficiently. Serving them directly means slow starts, buffering on mobile, and no quality adaptation when bandwidth changes.

Add videoStreamingPlugin to your Better Media pipeline and uploaded videos are automatically transcoded into adaptive bitrate streams. Your users get smooth playback on any device and network — you get a single recordId to resolve or proxy the stream from.

Additional features

  • HLS and DASH from a single upload
  • Real-time transcode progress events
  • Built-in serving utilities for public, signed, and proxied delivery

Direct file serving vs. adaptive streaming

Direct file servingAdaptive streaming (this plugin)
Startup timePlayer must buffer before playingStarts on the first short segment (~6 s)
Quality adaptationFixed — one file, one qualitySwitches automatically as bandwidth changes
Mobile / slow connectionsBuffers or stallsDrops to a lower quality and keeps playing
SeekingDepends on server range request supportAlways fast — seeks jump to the right segment
Browser compatibilityDepends on the file's codecHLS and DASH work on every modern platform

Use direct file serving for short clips or previews. Use this plugin when you need reliable playback across devices and network conditions.

Prerequisites

ffmpeg must be installed and available in PATH on the server running your pipeline.

# macOS
brew install ffmpeg

# Ubuntu / Debian
apt-get install ffmpeg

Installation

Install the package

pnpm add @better-media/plugin-video-streaming fluent-ffmpeg
pnpm add -D @types/fluent-ffmpeg
npm install @better-media/plugin-video-streaming fluent-ffmpeg
npm install --save-dev @types/fluent-ffmpeg
yarn add @better-media/plugin-video-streaming fluent-ffmpeg
yarn add --dev @types/fluent-ffmpeg

Register the plugin

import { videoStreamingPlugin } from "@better-media/plugin-video-streaming";

const media = createBetterMedia({
  // ...
  plugins: [
    videoStreamingPlugin(),
  ],
});

Transcoding is queued and processed in the background — the upload response returns immediately.

Usage

Uploading a video

Upload a video file through your pipeline as you normally would. The plugin runs automatically — no extra steps on your end.

server.ts
const result = await media.upload.ingest({
  file: { path: req.file.path },
  metadata: {
    filename: req.file.filename,
    mimeType: req.file.mimetype,
    size: req.file.size,
  },
});

const { id: recordId } = result;

Checking transcoding status

Because the plugin runs in background mode, the upload response returns before transcoding finishes. Calling resolveStreamingUrls immediately after upload will return an empty result — the master playlist doesn't exist yet.

Use resolveStreamingStatus to check whether transcoding is complete before serving:

server.ts
import { resolveStreamingStatus } from "@better-media/plugin-video-streaming";

const status = await resolveStreamingStatus(recordId, { storage });
// { hls: true, dash: false, ready: true }

if (!status.ready) {
  return res.status(202).json({ message: "Transcoding in progress" });
}

Poll this from your client until ready is true, or use the onProgress callback to push status updates over a websocket.

Serving strategies

The plugin gives you three ways to deliver streams to clients. Choose based on your access control requirements.

StrategyHow segments are servedAccess control
A — Public URLDirectly from storage or CDNNone — anyone with the URL can watch
B — Signed URLDirectly from storage or CDNTime-limited master URL controls discovery
C — ProxyThrough your serverFull auth on every request

Public URL

If your videos are public and you want the simplest possible setup, use resolveStreamingUrls without expiresIn. Segments are served directly from storage or your CDN with no signing overhead.

server.ts
import { resolveStreamingUrls } from "@better-media/plugin-video-streaming";

const urls = await resolveStreamingUrls(recordId, { database, storage });
// { hls: "https://cdn.example.com/…/hls/master.m3u8" }

CORS

Your storage bucket needs CORS configured to allow the client origin — the player fetches manifests and segments directly from storage:

[{
  "AllowedOrigins": ["https://yourapp.com"],
  "AllowedMethods": ["GET", "HEAD"],
  "AllowedHeaders": ["*"],
  "ExposeHeaders": ["Content-Length", "Content-Type"]
}]

The S3 and GCS adapters expose a setCorsConfiguration() method for this — see the S3 and GCS storage adapter docs.

Signed URL

If you want to gate who can start watching without the cost of signing every segment, pass expiresIn. The master playlist URL is time-limited; segments are served publicly once the player has the manifest.

server.ts
const urls = await resolveStreamingUrls(recordId, {
  database,
  storage,
  expiresIn: 3600, // master URL expires in 1 hour
});

CORS

The same bucket-level CORS configuration from strategy A applies here. Strategy B requires your storage bucket to allow public reads on the streaming/ prefix — if your bucket is fully private, use Strategy C instead.

Proxy

If you need full access control on every request (subscription gating, per-user auth), proxy all traffic through your server. Auth runs on every segment — nothing reaches the client without passing your middleware.

Create the proxy once, then wire the handler into a route:

server.ts
import { createStreamingProxy } from "@better-media/plugin-video-streaming";

const proxy = createStreamingProxy({
  storage,

  // Runs before every request — master playlist, variant playlists, segments.
  // Throw any error to deny access (→ 403). Receives the original request
  // object and the resolved storage key so you can check per-record permissions.
  authenticate: async (req, { recordId }) => {
    const session = await getSession(req);
    if (!session.canView(recordId)) throw new Error("Forbidden");
  },

  // Playlists are re-written on every re-transcode — never cache them.
  // Segments are content-addressed and immutable — cache aggressively.
  cacheControl: {
    playlist: "no-cache, no-store",
    segment: "public, max-age=31536000, immutable",
  },

  // CORS origin sent on every response. Set to your app's origin in production.
  // Defaults to "*" if omitted.
  corsOrigin: "https://yourapp.com",
});

The handler returns a standard Web API Response, so it works across frameworks:

server.ts
import { Readable } from "node:stream";

app.get("/stream/:recordId/*", async (req, res) => {
  const response = await proxy.handle({
    recordId: req.params.recordId,
    filePath: req.params[0],
    req,
  });
  res.status(response.status);
  response.headers.forEach((v, k) => res.setHeader(k, v));
  Readable.fromWeb(response.body).pipe(res);
});
app/stream/[recordId]/[...filePath]/route.ts
export async function GET(
  req: Request,
  { params }: { params: { recordId: string; filePath: string[] } }
) {
  return proxy.handle({
    recordId: params.recordId,
    filePath: params.filePath.join("/"),
    req,
  });
}

export const HEAD = GET;
server.ts
import { Readable } from "node:stream";

fastify.get("/stream/:recordId/*", async (req, reply) => {
  const response = await proxy.handle({
    recordId: req.params.recordId,
    filePath: req.params["*"],
    req,
  });
  reply.status(response.status);
  response.headers.forEach((v, k) => reply.header(k, v));
  reply.send(Readable.fromWeb(response.body));
});

CORS

The proxy sets CORS headers on every response via corsOrigin — no bucket-level CORS configuration needed.

Tracking progress

If you want to show users live transcoding progress — via a websocket, SSE, or a polling endpoint — pass an onProgress callback when registering the plugin.

media.config.ts
videoStreamingPlugin({
  onProgress: (event) => {
    console.log(`${event.format} ${event.preset} — ${event.percent}%`);
    // { format: "hls", preset: "720p", percent: 42, currentTimeSecs: 38 }
  },
})

Client-side playback

Once you have a URL from resolveStreamingUrls or a proxy route, pass it to a player. The plugin returns a standard HLS or DASH URL — player choice is yours.

HLS.js

For browsers that don't support HLS natively (Chrome, Firefox). Falls back to native playback on Safari, which supports HLS without a library.

import Hls from "hls.js";

const { hls: hlsUrl } = await fetch(`/api/stream/${recordId}`).then(r => r.json());

if (Hls.isSupported()) {
  const hls = new Hls();
  hls.loadSource(hlsUrl);
  hls.attachMedia(videoElement);
} else if (videoElement.canPlayType("application/vnd.apple.mpegurl")) {
  // Safari — native HLS, no library needed
  videoElement.src = hlsUrl;
}

Shaka Player

Supports both DASH and HLS. Best choice for Smart TVs, Android, and any app that needs DASH. Pass the DASH URL when available, with HLS as a fallback.

import shaka from "shaka-player";

const { dash: dashUrl, hls: hlsUrl } = await fetch(`/api/stream/${recordId}`).then(r => r.json());

const player = new shaka.Player(videoElement);
await player.load(dashUrl ?? hlsUrl);

React Native

Use react-native-video with the HLS URL. iOS and Android both support HLS natively.

import Video from "react-native-video";

const { hls: hlsUrl } = await fetch(`/api/stream/${recordId}`).then(r => r.json());

<Video source={{ uri: hlsUrl }} resizeMode="contain" />;

Configuration

Custom presets

The default quality ladder is 360p, 480p, 720p, and 1080p. Override presets to control exactly which quality levels are produced — useful for reducing transcoding cost, targeting a specific audience, or matching your storage budget.

media.config.ts
import { videoStreamingPlugin } from "@better-media/plugin-video-streaming";

videoStreamingPlugin({
  presets: [
    { name: "360p",  height: 360,  videoBitrate: "800k",  audioBitrate: "96k"  },
    { name: "720p",  height: 720,  videoBitrate: "2500k", audioBitrate: "128k" },
    { name: "1080p", height: 1080, videoBitrate: "5000k", audioBitrate: "192k" },
  ],
})

Each preset produces one variant stream. The name is used in the storage key — keep it stable after launch or existing stream URLs will break.

Preset options:

FieldTypeDescription
namestringUsed in the storage path. Must be unique and stable.
heightnumberOutput height in pixels. Aspect ratio is preserved.
widthnumberOutput width in pixels. Use instead of or alongside height.
videoBitratestringTarget video bitrate, e.g. "2500k".
audioBitratestringTarget audio bitrate, e.g. "128k".
videoCodecstringVideo codec. Defaults to "libx264".
audioCodecstringAudio codec. Defaults to "aac".

Enabling DASH

By default the plugin produces HLS only. Add "dash" to formats to also generate a DASH manifest in the same transcoding run — no extra upload cost, no second ffmpeg pass.

media.config.ts
videoStreamingPlugin({
  formats: ["hls", "dash"],
})

Both manifests are stored and tracked in media_versions. resolveStreamingUrls returns both URLs when both are present:

server.ts
const urls = await resolveStreamingUrls(recordId, { database, storage });
// { hls: "https://…/hls/master.m3u8", dash: "https://…/dash/master.mpd" }

Use the DASH URL with Shaka Player or dash.js. Pass the HLS URL as a fallback for browsers or players that don't support DASH.

DASH is the better choice for Smart TVs, Android, and Shaka Player. HLS is required for Safari and iOS. Enabling both covers every platform.

Dynamic preset resolution

Use resolvePreset to adjust the quality ladder at runtime — per upload, per user, or per request. The callback runs once per preset before transcoding starts and returns the preset to use (or a modified version of it).

media.config.ts
videoStreamingPlugin({
  resolvePreset: async (context, preset) => {
    // Restrict free-tier users to 720p max
    if (context.metadata.plan === "free" && preset.name === "1080p") {
      return { ...preset, name: "720p", height: 720, videoBitrate: "2500k" };
    }
    return preset;
  },
})

context.metadata contains everything passed to media.upload.ingest() — subscription tier, user ID, content flags, or anything else your application puts there.

Allowed MIME types

The plugin only processes files whose mimeType matches the allowed list. Files that don't match are silently skipped — no error, no transcoding. The defaults cover the most common video formats:

video/mp4, video/quicktime, video/x-msvideo, video/x-matroska, video/webm, video/mpeg, video/ogg, video/3gpp, video/x-flv, video/x-ms-wmv

To add formats not in the default list, spread the defaults and append your additions:

media.config.ts
import { DEFAULT_VIDEO_MIME_TYPES, videoStreamingPlugin } from "@better-media/plugin-video-streaming";

videoStreamingPlugin({
  allowedMimeTypes: [...DEFAULT_VIDEO_MIME_TYPES, "video/x-m4v"],
})

To restrict processing to specific formats only, pass a new list without spreading the defaults.

Idempotency

By default the plugin checks whether the master playlist already exists in storage before transcoding. If it does, the entire run is skipped — re-uploading the same video or re-triggering the pipeline is safe and cheap.

Set skipExistingDerivatives: false when you need to force regeneration — for example after changing presets:

media.config.ts
videoStreamingPlugin({
  skipExistingDerivatives: false,
})

After changing presets, existing streams use the old quality ladder until regenerated. Set skipExistingDerivatives: false and re-trigger the pipeline for affected records.

Error handling

The plugin is designed to degrade gracefully — errors in one part of the run don't abort everything else.

SituationBehavior
ffmpeg not foundEmits { error: "ffmpeg-not-found" } metadata. Pipeline continues. No exception thrown.
One format failsEmits { error: "transcode-failed", format }. Other formats continue.
Timeout exceededTreated as a transcode failure. Emits { error: "transcode-failed", format }.
Upload failsEmits { error: "upload-failed", format }. Other formats continue.

The default timeout is 10 minutes. For large files or slower machines, increase it:

media.config.ts
videoStreamingPlugin({
  timeoutMs: 30 * 60 * 1000, // 30 minutes
})

Schema

The plugin writes to the media_versions table — one row for the master playlist and one row per variant playlist. Individual segments are not recorded.

media_versions
FieldTypeDescription
idPKstringUnique identifier for this version row.
mediaIdFKstringThe recordId from your upload result.
storageKeystringStorage path, e.g. "streaming/abc123/hls/master.m3u8".
mimeTypestring"application/x-mpegURL" for HLS playlists, "application/dash+xml" for DASH manifests.
sizenumberFile size in bytes.
type"compressed"Always compressed for streaming outputs.
isOriginalbooleanAlways false — streaming outputs are derivatives of the original.
versionNumbernumberAuto-incremented per mediaId.
createdAtDateWhen the row was inserted.

resolveStreamingUrls queries this table to find the master playlist key for a given recordId.

Options

Full reference for videoStreamingPlugin(options).

OptionTypeDefaultDescription
formats("hls" | "dash")[]["hls"]Streaming formats to generate.
presetsStreamingPreset[]360p / 480p / 720p / 1080pQuality ladder. Each preset produces one variant stream.
resolvePresetfunctionCalled per-preset at runtime. Return a modified preset to adjust quality dynamically (e.g. based on subscription tier).
segmentDurationnumber6Segment length in seconds. Shorter segments improve seek accuracy; longer segments reduce playlist overhead.
derivativePrefixstring"streaming"Storage key prefix for all transcoded output.
allowedMimeTypesstring[]common video typesFiles whose mimeType is not in this list are skipped.
persistMediaVersionsbooleantrueWrite media_versions rows for master and variant playlists.
skipExistingDerivativesbooleantrueSkip transcoding if the master playlist already exists in storage.
timeoutMsnumber600_000Maximum time allowed for the full transcoding pass, in milliseconds.
onProgressfunctionCalled periodically during transcoding with format, preset, percent, and current timestamp.

On this page