Adaptive video streaming with FFmpeg and Vite
How I use FFmpeg and a Vite plugin to turn recordings into adaptive HLS video, served as static files through a CDN and played with Mux Player.

The debug demo video in my table of contents post is one of 18 videos on this site as of writing this post.
I first hosted the TOC demo on Mux in August 2025, then moved it to YouTube that December. I wasn’t using Mux’s analytics, and YouTube was a simpler way to host it without worrying about future costs. Having YouTube’s branding and related videos in the middle of a blog post felt excessive for a clip showing a few seconds of UI.
The My Next Filing demo was my first self-hosted video. I put an MP4 on the site and used the browser’s controls, which I’d still do for a short clip. For longer recordings, I wanted a sharp version for reading small text and smaller versions for slower connections. Each file also had to fit within Cloudflare’s 25 MiB limit for static assets.
I could have gone back to Mux for the encoding and quality switching. I’d still have uploads and playback IDs to manage separately from the site. I wanted videos to work like images already do here. Put the file in the repo, reference it, and let the build handle the rest.
Codecs, containers and HLS
An MP4 file holds compressed video, usually audio, and timing information. MP4 is the container format. H.264 and AV1 are video codecs, which define how the picture data is compressed and decoded. AAC does the same for audio. Either video codec can go inside an MP4.
HTTP Live Streaming (HLS) uses playlists to tell the player which files to request. A master playlist lists the available versions of a video, each with its own playlist of short segments. With several resolutions available, the player can switch as the connection changes. The playlists and segments are ordinary files served over HTTP.
Those segments need a container too. My pipeline puts H.264 in MPEG-TS (.ts) files and AV1 in fragmented MP4 (.m4s) files. The AV1 playlist also points to an init.mp4 file, which the player reads to initialize playback before loading the segments.
From a recording to public URLs
A Vite plugin handles the preparation and makes the files available in dev and production. Adding a video now looks like this:
src/data/videos/my-post/
walkthrough.mp4 the recording
walkthrough.vtt reviewed English captions, optional
walkthrough.json {"posterAt": 3}, optionalThe path becomes the video ID. That recording gets a master playlist at /videos/my-post/walkthrough/index.m3u8 and a poster beside it. I reference those URLs in the post. There isn’t a separate packaging command to remember.
Vite starts preparation
Vite already knows when I’m starting dev or making a build, so I let it start video preparation too.
The videoAssets plugin lives in scripts/build/plugins/videos.ts. With the rest of the app setup left out, this is how I register it:
import { defineConfig } from 'vite';
import { videoAssets } from './scripts/build/plugins/videos.ts';
export default defineConfig(({ isPreview }) => ({
plugins: isPreview ? [] : [videoAssets(process.cwd())],
}));Preview serves the completed build and can skip preparation. The app config also passes a separate metadata directory for dev and each build target, so they can run together.
Inside the plugin, prepareVideos finds the recordings and asks packageVideo to prepare anything missing from the cache. It returns a map from public URLs to files on disk. That map is how dev and the build find the finished packages.
The work starts in Vite’s config hook, with the returned URL map kept in assets:
config: {
order: 'pre',
async handler(_config, { command }) {
assets = await prepareVideos(root, generatedDir, {
background: command === 'serve',
});
},
},The source path becomes the video ID, so walkthrough.mp4 and walkthrough.mov would collide. Discovery rejects that pair before starting an encode. It also picks up each recording’s matching VTT captions and JSON poster settings.
A build waits for every video. Dev can use what’s already cached while missing packages encode in the background. A new video’s URLs won’t work until its encode finishes. Then its files join the map, and the terminal prints the playlist URL.
When I change a recording, captions or poster settings, the content watcher restarts dev. An encode already running in the same process survives that restart. Only the current dev instance adds its results to the map.
FFmpeg builds the streaming package
Most of the work happens in packageVideo, in scripts/build/plugins/video-package.ts. It uses ffprobe to read the recording’s dimensions and rotation, then works out which sizes to encode.
The targets are 1080p, 720p and 480p, preserving the source’s shape and never upscaling it. Each size gets both an H.264 and an AV1 version, so browsers without AV1 support can use H.264.
I use x264 at CRF 20 for H.264 and SVT-AV1 at CRF 30 for AV1. CRF is the encoder’s quality setting. These settings assume SDR recordings, and the machine running the build needs FFmpeg with both encoders, plus ffprobe.
The cuts between segments have to line up for quality switching to work. I use six-second segments at 30 fps and force a keyframe at each boundary. A keyframe can be decoded without earlier frames, so the player can pick up the next segment from another resolution at the same moment in the recording. FFmpeg writes a media playlist for each version with -hls_time 6 and -hls_playlist_type vod.
The player also needs to know how much data each version requires. The packager gets those bandwidth figures from the encoded segment sizes and durations, then writes them into the master playlist alongside the resolution, codec and media-playlist URL. I list AV1 first, though decoder support and the player’s adaptation logic determine what actually plays.
The poster comes from the same encode, at the time specified in the optional JSON file. Captions need an extra adjustment.
I review the transcription and word-level timestamps before saving the VTT file beside the recording. Its cues count from zero, while FFmpeg gives my H.264 segments a later starting timestamp. The build offsets AV1 to match H.264 and adds X-TIMESTAMP-MAP to the HLS copy of the captions. With that mapping, captions stay in sync after seeking or switching quality.
Reusing completed packages
Encoding 20 minutes of video in two codecs takes long enough that I want the next build to reuse the work. Each finished package gets a cache key made from the recording, the tools that encoded it, the caption text and the poster time:
const fingerprint = createHash('sha256')
.update([inputHash, tools, captions, posterAt].join('\n'))
.digest('hex');The tools part includes the packager and rendition code hashes, plus the output of ffmpeg -version. That catches changes to encoder settings even when the recording hasn’t changed. If all those inputs match, the package under .cache/video-packages/files/<fingerprint>/ is ready to use again.
A fresh encode goes into a staging directory first. The packager checks that it decodes, that the segments line up, and that every file fits the host’s size limit. Only then does it rename the directory into the cache. A failed encode leaves no completed package for the site to pick up.
Serving files in dev and production
During dev, the files can stay in the cache. Middleware added through Vite’s configureServer hook looks up each requested video URL and serves the matching file with the correct media type. There’s no extra copy sitting in public.
Deployment needs those files in the build output. The plugin’s writeBundle hook copies them there, preserving the paths used in dev:
writeBundle: {
order: 'pre',
sequential: true,
handler() {
if (this.environment.name !== 'client') return;
const output = path.resolve(root, this.environment.config.build.outDir);
for (const [url, file] of assets) {
const target = path.join(output, url.slice(1));
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(file, target);
}
},
},The environment check keeps the copy in the client build, where the public files belong. A post can use the same /videos/… URLs while I’m editing it and after deployment.
Only packages for current source files enter that map. Removing a recording leaves its old cache entry alone, but excludes it from the next build.
Cloudflare’s static assets ignored byte-range requests, which players use to ask for part of a file, often when seeking. I added a Worker handler to return those bytes. The plugin writes the file-size metadata it needs, and dev uses the same handler so seeking works the same way in both places.
Playing the generated package
I use Mux’s open-source player with a src pointing at the generated playlist. It can play self-hosted HLS, so I get its controls without uploading the video to Mux.
The player registers itself when I import its package in the browser:
import '@mux/mux-player';The markup points to the playlist and poster produced by the build:
<mux-player
src="/videos/my-post/walkthrough/index.m3u8"
poster="/videos/my-post/walkthrough/poster.webp"
stream-type="on-demand"
aria-label="Video demo"
preload="none"
disable-tracking
></mux-player>The wrapper waits until the video is close to the viewport before importing the player or loading its poster. A demo further down the post can wait until someone gets to it.
The src attribute is less prominently documented than playback IDs, so I pin the player version (3.14.0) and test playback after upgrades. I turn tracking off and swap Mux’s analytics SDK out during the build. Browser tests check that playback makes no requests to Mux.
What the pipeline produces
The whole library is 18 videos, about 20 minutes of footage. That’s 152 MiB of recordings, turned into roughly 500 MiB across 1,255 files. The largest single file is 4.7 MiB, well under Cloudflare’s 25 MiB cap.
The AV1 media came out 36% smaller than H.264 at my settings. Individual videos ranged from 18% to 47% smaller. Those measurements cover every prepared size, including audio and container overhead.
That compares file sizes only. The CRF quality settings aren’t comparable across encoders. I haven’t measured whether the two outputs look identical. Check small text in your own recordings before trusting the savings.
AV1 also takes noticeably longer to encode in my builds. Keeping both versions makes the library on disk bigger. What a viewer downloads still depends on the codec and quality selected, buffering, and how long they watch.
What I still have to maintain
There’s no playback dashboard. Server logs tell me a segment was requested, not whether someone’s video kept buffering.
Earlier versions included a native MP4 fallback. Keeping those files under Cloudflare’s 25 MiB cap meant cutting some recordings to 480p. I also didn’t want a direct link to each complete video that someone could reuse elsewhere and run up my bandwidth usage, so I removed the MP4s. The HLS playlists and segments are still public, so downloads and hotlinking are still possible.
Playback now depends on JavaScript. With it off, readers get a reserved frame and a message. The poster also waits for JavaScript.
My automated tests play AV1 in Chromium. That doesn’t tell me how an older iPhone behaves, which means testing by hand on real devices.
Every video request also runs through the Worker that handles byte ranges, so delivery isn’t free compute. Public video URLs can change their contents when I replace a recording, even though the local cache uses hashes. Those URLs can’t safely get a long-lived immutable cache policy.
A few of my original recordings were gone, so I recovered them from their best existing HLS output. That gives you a playable source, but no detail the earlier encode threw away. Keep your originals separately from the delivery files.
If you need user uploads, live streams, private or paid videos, or real playback analytics, use a platform. That’s a lot of work this pipeline doesn’t do.
For a portfolio with 20 minutes of video, this is more machinery than the problem strictly needed. But adding a video is now a file in a folder, and everything a viewer sees is mine to change. I’m happy to maintain it for that.


