Convert uploaded MP4 and MOV files to multi-bitrate HLS with Node.js, FFmpeg, and fluent-ffmpeg, then play them with hls.js or pre-signed S3 and R2 URLs.

HTTP Live Streaming (HLS) is the de facto standard for adaptive video delivery on the modern web. Netflix, YouTube, and Twitch all use HLS to serve smooth video regardless of a viewer's connection speed. In this tutorial you'll build a Node.js service that accepts a raw video upload and outputs a full HLS stream — the same architecture behind my open-source HLS Transcoder project.
What You Will Build
A Node.js HTTP server that:
- Accepts a video file upload (MP4, MOV, etc.)
- Invokes FFmpeg to produce three adaptive bitrate renditions (1080p, 720p, 360p)
- Produces
.m3u8playlist files and.tssegment files - Serves the HLS output so any compatible player can stream it
By the end you'll have a working transcoder on localhost you can test with VLC or any HLS-capable browser.
Why HLS?
HLS splits a video into small .ts segments (typically 6 seconds each) and generates a playlist file (.m3u8) that describes their order and duration. An adaptive bitrate (ABR) master playlist points to multiple renditions at different qualities. The player automatically picks the best rendition based on the viewer's available bandwidth and switches mid-stream without buffering.
Why build your own instead of using a cloud service? Cost control, offline capability, and full ownership of the pipeline. A cloud transcoder charges per minute of video processed; a self-hosted FFmpeg node on a $6 VPS handles hours of video for cents.
Prerequisites
- Node.js 18+ and npm
- FFmpeg installed on the system (
brew install ffmpegon macOS,sudo apt install ffmpegon Ubuntu) - Basic familiarity with Express
Verify FFmpeg is available:
ffmpeg -version
# ffmpeg version 6.x Copyright (c) 2000-2024 the FFmpeg developersProject Setup
mkdir hls-transcoder && cd hls-transcoder
npm init -y
npm install express multer fluent-ffmpeg
mkdir uploads outputfluent-ffmpeg is a Node.js wrapper around the FFmpeg CLI that gives you a chainable builder API. multer handles multipart/form-data file uploads.
Building the Transcoder
1. Upload Endpoint
// index.js
const express = require("express")
const multer = require("multer")
const path = require("path")
const fs = require("fs")
const ffmpeg = require("fluent-ffmpeg")
const app = express()
const upload = multer({ dest: "uploads/" })
app.post("/transcode", upload.single("video"), async (req, res) => {
if (!req.file) return res.status(400).json({ error: "No file uploaded" })
const jobId = Date.now().toString()
const outputDir = path.join("output", jobId)
fs.mkdirSync(outputDir, { recursive: true })
try {
await transcodeToHLS(req.file.path, outputDir)
res.json({ jobId, playlist: `/stream/${jobId}/master.m3u8` })
} catch (err) {
res.status(500).json({ error: err.message })
}
})2. Adaptive Bitrate Transcoding
The core function runs FFmpeg to produce three renditions in parallel:
function transcodeToHLS(inputPath, outputDir) {
const renditions = [
{
name: "1080p",
size: "1920x1080",
videoBitrate: "4000k",
audioBitrate: "128k",
},
{
name: "720p",
size: "1280x720",
videoBitrate: "2500k",
audioBitrate: "128k",
},
{
name: "360p",
size: "640x360",
videoBitrate: "800k",
audioBitrate: "96k",
},
]
const jobs = renditions.map(
(r) =>
new Promise((resolve, reject) => {
ffmpeg(inputPath)
.videoCodec("libx264")
.videoBitrate(r.videoBitrate)
.audioCodec("aac")
.audioBitrate(r.audioBitrate)
.size(r.size)
.outputOptions([
"-hls_time 6",
"-hls_list_size 0",
`-hls_segment_filename ${path.join(outputDir, `${r.name}_%03d.ts`)}`,
"-f hls",
])
.output(path.join(outputDir, `${r.name}.m3u8`))
.on("end", resolve)
.on("error", reject)
.run()
})
)
return Promise.all(jobs).then(() =>
writeMasterPlaylist(outputDir, renditions)
)
}-hls_time 6 sets each segment to six seconds. -hls_list_size 0 keeps all segment entries in the playlist (required for VOD; use a positive number for live streams).
3. Master Playlist
The master playlist lets the player automatically switch renditions:
function writeMasterPlaylist(outputDir, renditions) {
const bandwidth = { "1080p": 4128000, "720p": 2628000, "360p": 896000 }
const resolutions = {
"1080p": "1920x1080",
"720p": "1280x720",
"360p": "640x360",
}
const lines = ["#EXTM3U", "#EXT-X-VERSION:3", ""]
renditions.forEach((r) => {
lines.push(
`#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth[r.name]},RESOLUTION=${resolutions[r.name]}`
)
lines.push(`${r.name}.m3u8`)
lines.push("")
})
fs.writeFileSync(path.join(outputDir, "master.m3u8"), lines.join("\n"))
}4. Serving the HLS Output
app.use(
"/stream",
express.static("output", {
setHeaders: (res, filePath) => {
if (filePath.endsWith(".m3u8"))
res.set("Content-Type", "application/vnd.apple.mpegurl")
if (filePath.endsWith(".ts")) res.set("Content-Type", "video/mp2t")
},
})
)
app.listen(3000, () =>
console.log("Transcoder running on http://localhost:3000")
)Testing
Upload a sample video and get back the playlist URL:
curl -X POST http://localhost:3000/transcode \
-F "video=@sample.mp4" | jq .
# { "jobId": "1746403200000", "playlist": "/stream/1746403200000/master.m3u8" }Open http://localhost:3000/stream/<jobId>/master.m3u8 in VLC (File → Open Network) or Safari. To test in a browser with hls.js:
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<video id="player" controls></video>
<script>
const video = document.getElementById("player")
if (Hls.isSupported()) {
const hls = new Hls()
hls.loadSource("http://localhost:3000/stream/JOB_ID/master.m3u8")
hls.attachMedia(video)
}
</script>Production Considerations
Queue long jobs. FFmpeg is CPU-bound and blocks the event loop. In production, push jobs to a BullMQ queue and run transcoder workers as separate processes. This is exactly what the open-source HLS Transcoder does.
Write segments to object storage. Local disk doesn't scale horizontally. Pipe FFmpeg output to S3 or Cloudflare R2, then return pre-signed playback URLs. This keeps the transcoder stateless and allows multiple instances behind a load balancer.
Add CORS headers. If your video player lives on a different origin, add cors() middleware and ensure .m3u8 and .ts responses include the correct Access-Control-Allow-Origin header.
Containerize with FFmpeg baked in. A Dockerfile that installs FFmpeg at build time (apt-get install -y ffmpeg) makes the service portable across environments without runtime surprises.
What's Next
The full production implementation — with S3 integration, a job-status polling API, and a React/hls.js front-end — lives in the HLS Transcoder repository on GitHub. Clone it as a starting point or reference implementation.
If you're building a video platform, live-streaming product, or media processing pipeline and need an experienced full-stack engineer, I'm available for freelance and contract work.
Frequently asked questions
How do I convert an uploaded MP4 to adaptive HLS in a Node.js project?
The /transcode route in this post accepts an MP4 or MOV upload and runs fluent-ffmpeg to produce 1080p, 720p, and 360p HLS renditions, a master .m3u8 playlist, and .ts segments. The samples are JavaScript; the same FFmpeg pipeline runs in a TypeScript Node server.
How does hls.js play the transcoded stream?
The testing section loads hls.js from npm and, when Hls.isSupported() is true, calls loadSource with the master playlist URL and attachMedia on a video element. Safari can open the .m3u8 directly.
What does the FFmpeg HLS transcoding step output?
Each rendition is an HLS playlist of 6-second MPEG-TS segments (-hls_time 6). -hls_list_size 0 keeps every segment in the playlist, which is what on-demand playback needs. A master playlist lists the renditions so the player can switch bitrates.
Can this replace a video API that ingests RTMP and delivers HLS globally without my own CDN?
This post is a self-hosted FFmpeg pipeline. You upload a file, transcode it, and serve the playlist from the Node process. It does not accept an RTMP feed or hand delivery to a managed video API. For more than one machine, the production notes move long jobs onto a BullMQ queue and write segments to S3 or Cloudflare R2.
How do I gate on-demand HLS playback?
The production section stores segments in S3 or R2 and returns pre-signed playback URLs so the transcoder does not have to serve files itself. The article does not add a separate signed-token auth layer beyond those pre-signed URLs.
Is there a batch MOV conversion with webhooks?
The upload endpoint accepts MOV files and responds with a job id and playlist path. Long jobs are meant to go on a BullMQ queue, and the linked HLS Transcoder repo adds a job-status API you can poll. This article does not show webhook callbacks.