Adaptive video streaming in Go with FFmpeg
Use Go to run FFmpeg, package a local video as HLS or MPEG-DASH, and play the completed files in a browser. The example also creates a two-representation DASH stream. This is a local packaging and playback walkthrough, not a production delivery service or a demonstration of adaptation under changing network conditions.
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 uses .mpd files. Both formats segment videos into
smaller chunks. A player needs multiple compatible representations to switch quality; splitting one
encoding into segments does not make it adaptive.
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. Use Bash for the shell commands, with Go, FFmpeg, ffprobe, and cURL on
PATH. The conversion was tested with Go 1.26.8 and FFmpeg/ffprobe 9.0.1 on Linux. Your FFmpeg build
needs the libx264 and AAC encoders and the HLS and DASH muxers:
go version && ffmpeg -version && ffprobe -version && ffmpeg -encoders && ffmpeg -muxers
Work in a directory containing a valid local landscape video named input.mp4, with an audio track
and a height of at least 360 pixels. Save the two Go programs by the filenames shown below and run
them individually; go run . would combine their two main functions. The converter normalizes video
to 24 frames per second and aligns 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.
After all three conversions succeed, move their output directories together:
test -f hls-output/index.m3u8 &&
test -f dash-output/index.mpd &&
test -f adaptive-output/index.mpd &&
mkdir videos &&
mv hls-output dash-output adaptive-output videos/
An existing videos directory makes this command stop without replacing its contents. Use a fresh
project directory for another complete replay rather than deleting existing data.
Save this separate program as serve.go. It binds to loopback and sets the relevant MIME types.
Go’s http.FileServer enables directory listings here, so
do not put private files in the public directory.
package main
import (
"flag"
"fmt"
"log"
"mime"
"net"
"net/http"
"time"
)
func main() {
port := flag.Int("port", 8080, "Loopback port for local playback")
flag.Parse()
for extension, contentType := range map[string]string{
".m3u8": "application/vnd.apple.mpegurl", ".mpd": "application/dash+xml",
".ts": "video/mp2t", ".m4s": "video/iso.segment", ".mp4": "video/mp4",
} {
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"))))
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", *port))
if err != nil {
log.Fatal(err)
}
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Printf("Serving local test media at http://%s/videos/", listener.Addr())
log.Fatal(server.Serve(listener))
}
Download a pinned Shaka Player build into the same public directory. This command needs internet access and replaces an existing copy of the library at that filename. Continue only after the download succeeds:
curl -fsSLo videos/shaka-player.compiled.js https://cdn.jsdelivr.net/npm/shaka-player@5.2.12/dist/shaka-player.compiled.js
Save this page as videos/player.html. It follows Shaka’s
basic setup and
loads the manifests relative to the page, so media and player share one origin:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Local HLS and DASH playback</title>
<style>
html { color-scheme: light dark; }
video { max-width: 100%; height: auto; }
</style>
<script src="shaka-player.compiled.js"></script>
</head>
<body>
<form id="streams">
<label>Stream
<select id="manifest" disabled>
<option value="hls-output/index.m3u8">HLS</option>
<option value="dash-output/index.mpd">DASH</option>
<option value="adaptive-output/index.mpd">Adaptive DASH</option>
</select>
</label>
<button disabled>Load stream</button>
</form>
<p id="status" role="status">Initializing player…</p>
<video id="video" aria-label="Video preview" controls width="640"></video>
<script>
async function initialize() {
const status = document.getElementById('status')
const form = document.getElementById('streams')
const manifest = document.getElementById('manifest')
const button = form.querySelector('button')
shaka.polyfill.installAll()
if (!shaka.Player.isBrowserSupported()) {
status.textContent = 'This browser cannot play these streams.'
return
}
const player = new shaka.Player()
await player.attach(document.getElementById('video'))
window.player = player
player.addEventListener('error', () => {
status.textContent = 'Playback failed. Check the manifest and segments.'
})
form.addEventListener('submit', async (event) => {
event.preventDefault()
button.disabled = manifest.disabled = true
status.textContent = 'Loading stream…'
try {
await player.load(manifest.value)
status.textContent = 'Ready. Press play.'
} catch {
status.textContent = 'Playback failed. Check the manifest and segments.'
} finally {
button.disabled = manifest.disabled = false
}
})
button.disabled = manifest.disabled = false
status.textContent = 'Choose a stream.'
}
initialize().catch(() => {
document.getElementById('status').textContent = 'Player initialization failed.'
})
</script>
</body>
</html>
From the directory containing serve.go and videos, start the server in the foreground:
go run serve.go -port 8080
Open http://127.0.0.1:8080/videos/player.html in a browser with H.264/AAC and Media Source
Extensions support. Choose a stream, click Load stream, wait for
Ready. Press play., and use the video’s play control. Try all
three entries and seek near the end. Stop the server with Ctrl+C. If port 8080 is occupied, choose
another -port value and use that port in the browser URL. A bind failure exits with an error rather
than announcing a serving URL. Playback was checked with Shaka Player 5.2.12 and Chromium 145.
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
The player above checks local playback. It does not prove that a bitrate ladder looks good or that automatic switching performs well under real network conditions. Review the decoded video and audio, including the end of the recording, and check FFmpeg’s diagnostics before publishing.
A missing input, missing audio track, or failed FFmpeg process returns a nonzero status. The runner removes the new output directory after a conversion failure and refuses to overwrite an existing destination. FFmpeg can recover from some damaged inputs and still return success; “Conversion complete” is not a guarantee that the input was intact. Use known-valid source files and compare the output’s content and duration with the source.
From the project directory, inspect the adaptive DASH streams and decode all streams in each completed output. Absolute manifest paths avoid ambiguity when the DASH demuxer resolves segment filenames:
(
decodeStream() {
local diagnostics
if diagnostics=$(ffmpeg -v error -xerror -i "$1" -map 0 -f null - 2>&1); then
if [ -z "$diagnostics" ]; then return 0; fi
fi
printf 'Decode check failed for %s\n%s\n' "$1" "$diagnostics" >&2
return 1
}
ffprobe -v error -count_frames -show_entries stream=codec_name,codec_type,width,height,nb_read_frames -of json "$PWD/videos/adaptive-output/index.mpd" &&
decodeStream "$PWD/videos/hls-output/index.m3u8" &&
decodeStream "$PWD/videos/dash-output/index.mpd" &&
decodeStream "$PWD/videos/adaptive-output/index.mpd"
)
The JSON should list H.264 video at heights of 180 and 360 pixels, plus AAC audio. Width depends on
your source’s aspect ratio. Compare nb_read_frames
for both video representations: the same input and frame-rate normalization should produce the
same count. A damaged final fragment can silently shorten one representation even when FFmpeg
returns zero without an error diagnostic. An unequal count needs investigation, not publication.
Also review the final picture and audio against the source; equal counts alone do not prove intact
content. The decoder checks are silent on success and stop the chain on a failed process or an error
diagnostic, even if FFmpeg
recovers and returns zero. They still do not establish that the original recording was intact or
that every expected frame survived. AAC padding can make the audio slightly longer than the video,
so compare the decoded content and timestamps rather than relying on a rounded manifest duration
alone.
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
You now have a local HLS stream, a single-video DASH stream, and a DASH stream with two video representations. Keep each manifest with its segments when moving from this loopback check to your publishing setup.
If you're looking for a managed solution, Transloadit's 🤖 /video/adaptive Robot handles HLS and MPEG-DASH packaging. See our go-sdk for integration with Go.
