Streaming adaptativo de video en Go con FFmpeg
Usa Go para ejecutar FFmpeg, empaquetar un video local en HLS o MPEG-DASH y reproducir los archivos terminados en un navegador. El ejemplo también crea un flujo DASH con dos variantes. Este tutorial explica el empaquetado y la reproducción locales; no es un servicio de entrega en producción ni una demostración de adaptación a condiciones de red cambiantes.
Introducción al streaming adaptativo: HLS frente a MPEG-DASH
El streaming con tasa de bits adaptativa ajusta dinámicamente la calidad del video según las
condiciones de red del espectador. Dos estándares populares son HLS
(desarrollado por Apple) y MPEG-DASH (un estándar abierto). HLS
usa archivos de manifiesto .m3u8, mientras que MPEG-DASH usa archivos .mpd.
Ambos formatos dividen los videos en segmentos más pequeños. Un reproductor necesita varias variantes
compatibles para cambiar de calidad; dividir una codificación en segmentos no la hace adaptativa.
Configurar FFmpeg con Go: elegir el wrapper adecuado
Esta guía usa el paquete estándar os/exec de Go y la interfaz
de línea de comandos documentada de FFmpeg.
No se necesita un wrapper de Go. Usa Bash para los comandos de shell, con Go, FFmpeg, ffprobe y cURL en
PATH. La conversión se probó con Go 1.26.8 y FFmpeg/ffprobe 9.0.1 en Linux.
Tu compilación de FFmpeg necesita los codificadores libx264 y AAC y los multiplexores HLS y DASH:
go version && ffmpeg -version && ffprobe -version && ffmpeg -encoders && ffmpeg -muxers
Trabaja en un directorio que contenga un video local válido en orientación horizontal llamado
input.mp4, con una pista de audio y una altura de al menos 360 píxeles.
Guarda los dos programas de Go con los nombres de archivo que se muestran a continuación y ejecútalos
por separado; go run . combinaría sus dos funciones main.
El conversor normaliza el video a 24 fotogramas por segundo y alinea los fotogramas clave cada cuatro
segundos. Adapta las dimensiones y la escala de tasas de bits a tu material de origen; generar dos
variantes no es, por sí solo, una recomendación de calidad.
Convertir videos al formato HLS con Go y FFmpeg
Guarda el siguiente programa completo como stream.go. El mismo ejecutor admite
HLS, DASH con una variante de video y DASH con dos variantes de video. Cada invocación requiere un
nuevo directorio de salida para que los manifiestos y segmentos no entren en conflicto con los de
un trabajo anterior. El programa inicia FFmpeg sin un 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")
}
Ejecuta:
go run stream.go input.mp4 hls-output hls
Esto produce una lista de reproducción de medios VOD y segmentos MPEG-TS con una sola tasa de bits de video. Eso es streaming segmentado, todavía no es streaming con tasa de bits adaptativa. No expongas el directorio de salida a los espectadores hasta que el comando termine correctamente.
Implementar la conversión a MPEG-DASH en Go
Usa el mismo programa con el modo dash:
go run stream.go input.mp4 dash-output dash
La salida contiene index.mpd, segmentos de inicialización y segmentos de medios.
Al publicar, mantenlos juntos y conserva sus nombres de archivo relativos. Este modo tiene una
variante de video y un conjunto de adaptación de audio independiente.
Crear variantes con tasa de bits adaptativa
El modo adaptive-dash mapea el video de entrada dos veces, escala cada salida
y asigna tasas de bits independientes. Configurar solo -b:v:1 no crea un
segundo flujo de video; el mapeo adicional es esencial. Ambas variantes comparten fotogramas clave
alineados y un flujo de audio.
go run stream.go input.mp4 adaptive-output adaptive-dash
Usa una fuente de al menos 360 píxeles de altura para esta escala ilustrativa de 180p/360p e inspecciona el texto, el movimiento y la relación de aspecto en ambas calidades. Las escalas de tasas de bits en producción deben reflejar tu contenido y los dispositivos de destino. HLS con múltiples variantes necesita además varias listas de reproducción de medios y una lista de reproducción maestra; el modo HLS de una sola variante mostrado antes no las crea.
Crear un servidor de streaming con manejo de segmentos
Para una prueba de reproducción local, coloca solo resultados de prueba terminados y públicos
en un directorio videos.
Una vez que las tres conversiones terminen correctamente, mueve sus directorios de salida juntos:
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/
Si ya existe un directorio videos, este comando se detiene sin reemplazar
su contenido. Usa un directorio de proyecto nuevo para repetir todo el procedimiento en lugar de
eliminar los datos existentes.
Guarda este programa independiente como serve.go. Se vincula a la interfaz
loopback y establece los tipos MIME pertinentes.
El componente http.FileServer de Go habilita aquí los listados de
directorios, así que no coloques archivos privados en el directorio público.
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))
}
Descarga una compilación de una versión fija de Shaka Player en el mismo directorio público. Este comando necesita acceso a internet y reemplaza cualquier copia existente de la biblioteca con ese nombre de archivo. Continúa solo cuando la descarga termine correctamente:
curl -fsSLo videos/shaka-player.compiled.js https://cdn.jsdelivr.net/npm/shaka-player@5.2.12/dist/shaka-player.compiled.js
Guarda esta página como videos/player.html. Sigue la
configuración básica de Shaka y
carga los manifiestos con rutas relativas a la página, por lo que los medios y el reproductor
comparten un mismo origen:
<!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>
Desde el directorio que contiene serve.go y videos, inicia el servidor
en primer plano:
go run serve.go -port 8080
Abre http://127.0.0.1:8080/videos/player.html en un navegador compatible con H.264/AAC y Media Source
Extensions. Elige un flujo, haz clic en Load stream, espera a que aparezca
Ready. Press play. y usa el control de reproducción del video. Prueba las
tres opciones y desplázate hasta cerca del final. Detén el servidor con Ctrl+C. Si el puerto 8080 está
ocupado, elige otro valor de -port y usa ese puerto en la URL del navegador.
Si falla la vinculación, el programa termina con un error en lugar de anunciar una URL de servicio.
La reproducción se comprobó con Shaka Player 5.2.12 y Chromium 145.
Optimizar el rendimiento con procesamiento concurrente
Comienza con un worker de conversión y mide el uso de CPU, memoria y disco. Aumenta la concurrencia con un grupo de workers de tamaño limitado, no con una goroutine por cada subida en cola. Asigna a cada trabajo su propio directorio de salida e informa de cada fallo a la cola para que pueda reintentarse de forma deliberada. El tiempo de espera del programa limita un proceso de FFmpeg; no reemplaza los límites del sistema operativo ni un entorno aislado.
Para medios no confiables, usa workers aislados sin credenciales ni acceso irrestricto a la red. Publica los resultados terminados mediante un servidor web o una CDN configurados correctamente, con HTTPS, reglas de caché, controles de acceso donde sean necesarios y CORS solo para los orígenes de reproductor que quieras admitir. El servidor de desarrollo en loopback no es un servicio de streaming para producción.
Probar y depurar tu implementación de streaming
El reproductor anterior comprueba la reproducción local. No demuestra que una escala de tasas de bits se vea bien ni que el cambio automático funcione bien en condiciones de red reales. Revisa el video y el audio decodificados, incluido el final de la grabación, y comprueba los diagnósticos de FFmpeg antes de publicar.
Si falta la entrada o la pista de audio, o si falla el proceso de FFmpeg, se devuelve un estado distinto de cero. El ejecutor elimina el nuevo directorio de salida después de un fallo de conversión y se niega a sobrescribir un destino existente. FFmpeg puede recuperarse de algunas entradas dañadas y aun así indicar que terminó correctamente; «Conversion complete» no garantiza que la entrada estuviera intacta. Usa archivos de origen que sepas que son válidos y compara el contenido y la duración de la salida con los de la fuente.
Desde el directorio del proyecto, inspecciona los flujos DASH adaptativos y decodifica todos los flujos de cada salida terminada. Las rutas absolutas de los manifiestos evitan ambigüedades cuando el demultiplexor DASH resuelve los nombres de archivo de los segmentos:
(
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"
)
El JSON debe listar video H.264 con alturas de 180 y 360 píxeles, además de audio AAC. El ancho depende
de la relación de aspecto de tu fuente. Compara nb_read_frames
en ambas variantes de video: la misma entrada y la normalización de la frecuencia de fotogramas
deberían producir el mismo recuento. Un fragmento final dañado puede acortar silenciosamente una
variante incluso cuando FFmpeg devuelve cero sin un diagnóstico de error. Si los recuentos difieren,
hay que investigar, no publicar. Revisa también la imagen y el audio finales comparándolos con la
fuente; unos recuentos iguales no demuestran por sí solos que el contenido esté intacto. Las
comprobaciones del decodificador no emiten mensajes cuando terminan correctamente y detienen la
cadena ante un proceso fallido o un diagnóstico de error, incluso si FFmpeg se recupera y devuelve
cero. Aun así, no demuestran que la grabación original estuviera intacta ni que se hayan conservado
todos los fotogramas esperados. El relleno de AAC puede hacer que el audio sea ligeramente más largo
que el video, así que compara el contenido decodificado y las marcas de tiempo en lugar de basarte
únicamente en una duración redondeada del manifiesto.
Comprueba también que cada segmento de inicialización y de medios referenciado exista, tenga el
tipo MIME esperado y devuelva una respuesta satisfactoria. Para adaptive-dash,
inspecciona el MPD para verificar que tenga dos variantes de video en lugar de inferir la adaptación
a partir de un nombre de archivo. Consulta las
opciones de los multiplexores DASH y HLS de FFmpeg para conocer los detalles del empaquetado.
Conclusión
Ahora tienes un flujo HLS local, un flujo DASH con una variante de video y un flujo DASH con dos variantes de video. Mantén cada manifiesto junto con sus segmentos al pasar de esta prueba en loopback a tu entorno de publicación.
Si buscas una solución administrada, el Robot 🤖 /video/adaptive de Transloadit se encarga del empaquetado HLS y MPEG-DASH. Consulta nuestro go-sdk para la integración con Go.
