Runtime · Utilities
Bun.Video
Read video metadata, extract frames, audio and GIFs, remux between MP4, MOV, Matroska and WebM, and cut HLS segments with Bun's native video API.
Bun.Video is Bun's native video API. Its built-in decoder (FilmCraft, reported
by Bun.Video.decoder) reads media and decodes frames; the
Bun.Image pipeline transforms and encodes those frames.
These operations run inside Bun without launching an external media executable.
if (!Bun.Video.available) throw new Error("This runtime has no native video provider");
const video = new Bun.Video("episode.mp4");
const metadata = await video.metadata();
await video.frame(480).resize(640, 360).webp({ lossless: true }).write("frame.webp");
Use the global Bun.Video class; there is no module to import. The separate
@aphrody/bun-video package adds composition engines, HLS/DASH, browser capture
and optional FFmpeg/mpv adapters on top of it.
Bun.Video.available reports whether this build of Bun includes the native
decoder; it does not guarantee support for every codec. MP4 and QuickTime input
keeps the budgeted sample-table path. Other containers (WebM, Matroska, AVI, MPEG-TS and the
rest that FilmCraft opens) decode frames, audio and GIFs through FilmCraft's container owners;
remux and hls read MP4, QuickTime, Matroska and WebM. For Matroska and WebM frameCount is the
exact packet count of the video track; for AVI and the other containers it is the duration times the
frame rate, rounded. The tests decode VP8 and VP9 WebM, H.264 Matroska and MJPEG AVI fixtures; codec profiles outside those are unqualified and may be rejected. Use Bun.Image for still images.
Command line
bun video exposes the same native API on the command line. Inspect the
decoder, read metadata and extract frames from a local file:
bun video info --json
bun video metadata clip.mp4 --json
bun video frame clip.mp4 preview.webp --time 1 --width 320 --height 180 --format webp
bun video frames clip.mp4 frames --start 0 --end 1 --step 0.25 --max-frames 4
bun video audio clip.mp4 audio.wav --start 0 --duration 1 --sample-rate 16000
bun video gif clip.mp4 preview.gif --duration 1 --fps 8 --width 64
bun video remux clip.mp4 remux.mp4
bun video remux clip.mp4 clip.mkv
bun video hls clip.mp4 hls --segment-seconds 4
Output files must not already exist, and frames requires a new directory.
Use bun video --help for the options and --json for machine-readable
results. Composition and downloader commands such as bun video remotion are
provided by the optional Bun Video executable, which must be installed next to
the bun executable together with its release manifest.
Sources and metadata
The constructor accepts the same source types as Bun.Image: a local path,
bytes, a data URL or a Blob. Local movies use bounded reads from a regular file;
the file limit is 64 GiB. In-memory input is limited to 256 MiB. Treat path strings
as filesystem access and validate them before accepting untrusted input.
metadata() returns source width, height, duration, codec, container,
fpsNumerator, fpsDenominator, frameCount, fileBytes and audioStreams.
Each audio stream reports its sample rate, channels and codec. The source frame
rate remains rational; do not round 24000/1001 to 24 when selecting frames.
The constructor captures a source and settings without decoding it. width
and height begin at -1 and report the last transformed frame after a frame
terminal completes. Use metadata() for the source dimensions.
Frame transformations and raw pixels
frame(seconds) selects a non-negative presentation time. Chainable transforms
record settings; a terminal method schedules work off the JavaScript thread.
The order is shared with Image: autoOrient → extract → rotate → flip/flop → resize → modulate.
const pixels = await new Bun.Video("episode.mp4")
.frame(780)
.extract({ left: 320, top: 100, width: 800, height: 600 })
.resize(128, 96)
.rgba();
// { width, height, channels: 4, data: Uint8Array, iccProfile }
rgba() returns raw RGBA8 pixels without a PNG encode and decode round trip.
Other terminals mirror Image: bytes, buffer/toBuffer, blob, write,
toBase64, dataurl/toDataURL and placeholder. JPEG, PNG, WebP, HEIC and
AVIF output use Image's encoders and platform support. Video.backend shares
the Image transformation backend; it does not select a video decoder.
Bounded frame batches
frames({ start, end, step, maxFrames }) returns { time, bytes } entries with
source-resolution PNGs. end is exclusive. A batch is limited to 64 frames and
64 MiB of encoded output; choose a smaller batch for large frames.
const meta = await video.metadata();
const step = meta.fpsDenominator / meta.fpsNumerator;
const frames = await video.frames({ start: 0, end: 3 * step, step, maxFrames: 3 });
Each batch uses one decoder session. Frame batches preserve source resolution;
use frame(time) to transform an individual frame. This API does not keep an
entire episode decoded in memory.
Audio, GIF, remux and HLS
await Bun.write("audio.wav", await video.audio({ start: 10, duration: 20, sampleRate: 16000 }));
await Bun.write("moment.gif", await video.gif({ start: 638, duration: 5, fps: 12, width: 640 }));
await video.remux("episode-remux.mp4");
await video.remux("episode.mkv");
const segments = await video.hls("episode-hls", { segmentSeconds: 4 });
Audio extraction returns PCM16 WAV and is limited to 30 seconds and 64 MiB.
GIF export is palette-quantized and therefore lossy; it is limited to five
seconds, 24 frames per second, 640×360 and 32 MiB. Remux copies the encoded
packets into a new file without re-encoding; the extension selects the container
(.mp4, .m4v, .mov, .mkv or .webm). MP4 to MP4 keeps every access unit byte for
byte through the strict sample-table path; every other pair goes through FilmCraft's
container library, which refuses a codec the target cannot carry (for example H.264 in
WebM) and leaves no partial file. Remux refuses to overwrite an existing destination
and does not make the file smaller.
hls(directory, { segmentSeconds }) cuts an MP4, QuickTime, Matroska or WebM source into
CMAF segments without re-encoding and writes init.mp4, segment-00000.m4s, ... and a VOD
playlist.m3u8 into a directory that must not exist; it resolves to the segment count.
Segments are written as they are produced, so memory stays bounded by one segment. Each
segment ends at the first keyframe at or after segmentSeconds (0.5 to 60, default 6), so
a source with sparse keyframes yields longer segments. Codecs without an ISO base media
mapping, such as VP8, are rejected. Serve the directory with Accept-Ranges and CORS headers
and open playlist.m3u8 in any HLS player.
Frame analysis with WebGPU
The @aphrody/bun-video/frame-analysis export computes WGSL histograms and
detail metrics on the GPU. Pass raw Image or Video pixels directly:
import { analyzeFrameRgba } from "@aphrody/bun-video/frame-analysis";
const rgba = await video.frame(780).resize(128, 72).rgba();
const metrics = await analyzeFrameRgba(rgba, { backend: "chromium", timeout: 5000 });
The result reports the compute adapter that ran the analysis, and unsupported backends are rejected rather than replaced by a CPU fallback. Input is limited to 65536 pixels (each dimension from 4 to 256). Frame analysis does not use hardware video decoding.