Adaptive video streaming in Go with FFmpeg
Adaptive streaming is essential for delivering high-quality video content efficiently across various network conditions. In this DevTip, we'll explore how to implement adaptive video streaming in Go using FFmpeg, specifically focusing on HTTP Live Streaming (HLS) and MPEG-DASH formats. Before we dive in, ensure you have a Go development environment set up and FFmpeg installed on your system.
Introduction to adaptive streaming: HLS vs MPEG-DASH
Adaptive bitrate streaming dynamically adjusts video quality based on the viewer's network
conditions. Two popular standards are HLS (developed by Apple) and MPEG-DASH (an open standard). HLS
uses .m3u8 manifest files, while MPEG-DASH utilizes .mpd files. Both formats segment videos into
smaller chunks, allowing seamless quality adjustments.
Setting up FFmpeg with Go: choosing the right wrapper
This guide uses Go's standard os/exec package and FFmpeg's documented command-line interface.
No Go wrapper is needed. Check that Go and an FFmpeg build with the libx264 encoder are available:
go version
ffmpeg -version
ffmpeg -encoders
The examples use a local landscape video with an audio track. They normalize video to 24 frames per second and align keyframes every four seconds. Adapt the dimensions and bitrate ladder for your source material; generating two representations is not by itself a quality recommendation.
Converting videos to HLS format with Go and FFmpeg
Save the following complete program as stream.go. The same runner supports HLS, single-video
DASH, and two-video-representation DASH. Each invocation requires a new output directory so
manifests and segments cannot collide with an earlier job. It launches FFmpeg without a shell.
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
)
func convert(inputPath, outputPath, format string) (err error) {
if format != "hls" && format != "dash" && format != "adaptive-dash" {
return fmt.Errorf("format must be hls, dash, or adaptive-dash")
}
input, err := filepath.Abs(inputPath)
if err != nil {
return err
}
info, err := os.Stat(input)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("input must be a regular local file")
}
output, err := filepath.Abs(outputPath)
if err != nil {
return err
}
if err = os.Mkdir(output, 0700); err != nil {
return fmt.Errorf("use a new output directory: %w", err)
}
defer func() {
if err != nil {
if cleanupErr := os.RemoveAll(output); cleanupErr != nil {
fmt.Fprintln(os.Stderr, "Cannot remove partial output:", cleanupErr)
}
}
}()
args := []string{"-nostdin", "-n", "-i", input, "-map", "0:v:0"}
if format == "adaptive-dash" {
args = append(args, "-map", "0:v:0")
}
args = append(args, "-map", "0:a:0", "-c:v", "libx264", "-preset", "fast",
"-pix_fmt", "yuv420p", "-r", "24", "-g", "96", "-keyint_min", "96",
"-sc_threshold", "0", "-force_key_frames", "expr:gte(t,n_forced*4)",
"-c:a", "aac", "-b:a", "96k", "-ac", "2", "-threads", "2", "-filter_threads", "1")
if format == "adaptive-dash" {
args = append(args, "-filter:v:0", "scale=-2:180", "-b:v:0", "400k",
"-filter:v:1", "scale=-2:360", "-b:v:1", "800k")
} else {
args = append(args, "-b:v", "800k")
}
if format == "hls" {
args = append(args, "-f", "hls", "-hls_time", "4", "-hls_playlist_type", "vod",
"-hls_segment_filename", "segment-%04d.ts", "index.m3u8")
} else {
args = append(args, "-f", "dash", "-seg_duration", "4", "-use_template", "1",
"-use_timeline", "1", "-adaptation_sets", "id=0,streams=v id=1,streams=a", "index.mpd")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
cmd.Dir = output
cmd.Stderr = os.Stderr
if err = cmd.Run(); err != nil {
return fmt.Errorf("conversion failed: %w", err)
}
return nil
}
func main() {
if len(os.Args) != 4 {
fmt.Fprintln(os.Stderr, "Usage: go run stream.go <input.mp4> <new-output-directory> <hls|dash|adaptive-dash>")
os.Exit(1)
}
if err := convert(os.Args[1], os.Args[2], os.Args[3]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("Conversion complete")
}
Run go run stream.go input.mp4 hls-output hls. This produces a VOD media playlist and MPEG-TS
segments with one video bitrate. That is segmented streaming, not yet adaptive bitrate streaming.
Do not expose the output directory to viewers until the command succeeds.
Implementing MPEG-DASH conversion in Go
Use the same program with the dash mode:
go run stream.go input.mp4 dash-output dash
The output contains index.mpd, initialization segments, and media segments. Keep their relative
filenames together when publishing. This mode has one video representation and a separate audio
adaptation set.
Creating adaptive bitrate variants
The adaptive-dash mode maps the input video twice, scales each output, and assigns separate
bitrates. Setting -b:v:1 alone does not create a second video stream; the additional mapping is
essential. Both representations share aligned keyframes and one audio stream.
go run stream.go input.mp4 adaptive-output adaptive-dash
Use a source at least 360 pixels high for this illustrative 180p/360p ladder, and inspect text, motion, and aspect ratio at both qualities. Production bitrate ladders should reflect your content and target devices. Multi-variant HLS additionally needs multiple media playlists and a master playlist; the single-variant HLS mode above does not create them.
Building a streaming server with segment handling
For a local playback check, place only completed, public test outputs in a videos directory.
Save this separate program as serve.go. It binds to loopback and sets the relevant MIME types.
Directory listings are enabled by this development server, so do not put private files there.
package main
import (
"log"
"mime"
"net/http"
"time"
)
func main() {
for extension, contentType := range map[string]string{
".m3u8": "application/vnd.apple.mpegurl", ".mpd": "application/dash+xml",
".ts": "video/mp2t", ".m4s": "video/iso.segment",
} {
if err := mime.AddExtensionType(extension, contentType); err != nil {
log.Fatal(err)
}
}
mux := http.NewServeMux()
mux.Handle("/videos/", http.StripPrefix("/videos/", http.FileServer(http.Dir("./videos"))))
server := &http.Server{Addr: "127.0.0.1:8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Println("Serving local test media at http://127.0.0.1:8080/videos/")
log.Fatal(server.ListenAndServe())
}
Optimizing performance with concurrent processing
Start with one conversion worker and measure CPU, memory, and disk use. Increase concurrency with a bounded worker pool, not one goroutine for every queued upload. Give each job its own output directory and report each failure to the queue so it can be retried deliberately. The program's timeout bounds one FFmpeg process; it does not replace operating-system limits or a sandbox.
For untrusted media, use isolated workers without credentials or unrestricted network access. Publish completed outputs through a properly configured web server or CDN with HTTPS, cache rules, access controls where needed, and CORS only for the player origins you intend to support. The loopback development server is not a production streaming service.
Testing and debugging your streaming implementation
Use tools like VLC media player or browser-based HLS/DASH players (e.g., Shaka Player, Video.js) to
test your streams. Verify that your manifest files (.m3u8 or .mpd) correctly reference the video
segments and that playback adapts to simulated network condition changes if your player supports it.
Check FFmpeg logs for any errors during transcoding.
Also check that every referenced initialization and media segment exists, has the expected MIME
type, and returns a successful response. For adaptive-dash, inspect the MPD for two video
representations rather than inferring adaptation from a filename. See the
FFmpeg DASH and HLS muxer options for packaging details.
Conclusion
Implementing adaptive streaming in Go with FFmpeg is straightforward and powerful. By following these steps, you can efficiently deliver high-quality video content tailored to your users' network conditions.
If you're looking for a managed solution, Transloadit's 🤖 /video/adaptive Robot simplifies adaptive streaming by handling HLS and MPEG-DASH conversions seamlessly. Check out our go-sdk for easy integration.
