Streaming adaptativo de vídeo em Go com FFmpeg
Use Go para executar o FFmpeg, empacotar um vídeo local em HLS ou MPEG-DASH e reproduzir os arquivos concluídos em um navegador. O exemplo também cria um fluxo DASH com duas representações. Este é um passo a passo de empacotamento e reprodução local, não um serviço de distribuição em produção nem uma demonstração de adaptação a condições variáveis de rede.
Introdução ao streaming adaptativo: HLS vs MPEG-DASH
O streaming com taxa de bits adaptativa ajusta dinamicamente a qualidade do vídeo com base nas
condições de rede de quem assiste. Dois padrões populares são o
HLS (desenvolvido pela Apple) e o MPEG-DASH (um padrão aberto).
O HLS usa arquivos de manifesto .m3u8, enquanto o MPEG-DASH usa arquivos
.mpd. Ambos os formatos dividem vídeos em segmentos menores. Um player
precisa de várias representações compatíveis para alternar a qualidade; dividir uma única
codificação em segmentos não a torna adaptativa.
Configurar o FFmpeg com Go: escolher o wrapper certo
Este guia usa o pacote padrão os/exec do Go
e a interface de linha de comando documentada do FFmpeg.
Não é necessário um wrapper para Go. Use Bash para os comandos de shell, com Go, FFmpeg, ffprobe e
cURL no PATH. A conversão foi testada com Go 1.26.8 e FFmpeg/ffprobe 9.0.1
no Linux. Sua compilação do FFmpeg precisa dos codificadores libx264 e AAC
e dos multiplexadores HLS e DASH:
go version && ffmpeg -version && ffprobe -version && ffmpeg -encoders && ffmpeg -muxers
Trabalhe em um diretório que contenha um vídeo local válido na orientação paisagem, chamado
input.mp4, com uma faixa de áudio e altura de pelo menos 360 pixels. Salve os
dois programas Go com os nomes de arquivo mostrados abaixo e execute-os individualmente;
go run . combinaria suas duas funções main.
O conversor normaliza o vídeo para 24 quadros por segundo e alinha os quadros-chave a cada quatro
segundos. Adapte as dimensões e a escala de taxas de bits ao seu material de origem; gerar duas
representações não é, por si só, uma recomendação de qualidade.
Converter vídeos para o formato HLS com Go e FFmpeg
Salve o programa completo a seguir como stream.go. O mesmo executor oferece
suporte a HLS, DASH com um único vídeo e DASH com duas representações de vídeo. Cada execução exige
um novo diretório de saída para que os manifestos e segmentos não entrem em conflito com os de uma
tarefa anterior. Ele inicia o FFmpeg sem um 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")
}
Execute:
go run stream.go input.mp4 hls-output hls
Isso produz uma playlist de mídia VOD e segmentos MPEG-TS com uma única taxa de bits de vídeo. Trata-se de streaming segmentado, ainda não de streaming com taxa de bits adaptativa. Não exponha o diretório de saída a quem assiste até que o comando seja concluído com sucesso.
Implementar a conversão MPEG-DASH em Go
Use o mesmo programa com o modo dash:
go run stream.go input.mp4 dash-output dash
A saída contém index.mpd, segmentos de inicialização e segmentos de mídia.
Mantenha-os juntos com seus nomes de arquivo relativos ao publicar. Esse modo tem uma representação
de vídeo e um conjunto de adaptação de áudio separado.
Criar variantes com taxa de bits adaptativa
O modo adaptive-dash mapeia o vídeo de entrada duas vezes, redimensiona cada
saída e atribui taxas de bits distintas. Definir apenas -b:v:1 não cria um
segundo fluxo de vídeo; o mapeamento adicional é essencial. As duas representações compartilham
quadros-chave alinhados e um fluxo de áudio.
go run stream.go input.mp4 adaptive-output adaptive-dash
Use uma fonte com pelo menos 360 pixels de altura para esta escala ilustrativa de 180p/360p e inspecione o texto, o movimento e a proporção de tela nas duas qualidades. As escalas de taxas de bits em produção devem refletir seu conteúdo e os dispositivos de destino. O HLS com várias variantes também precisa de várias playlists de mídia e uma playlist mestre; o modo HLS de variante única acima não as cria.
Criar um servidor de streaming com tratamento de segmentos
Para verificar a reprodução local, coloque somente saídas de teste públicas e concluídas em um
diretório videos. Depois que as três conversões forem concluídas com
sucesso, mova seus diretórios de saída 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/
Se já existir um diretório videos, esse comando será interrompido sem
substituir seu conteúdo. Use um novo diretório de projeto para outra execução completa, em vez de
excluir os dados existentes.
Salve este programa separado como serve.go. Ele escuta no endereço de loopback
e define os tipos MIME relevantes. O http.FileServer
do Go habilita a listagem de diretórios aqui, portanto não coloque arquivos privados no diretório
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))
}
Baixe uma compilação de versão fixa do Shaka Player no mesmo diretório público. Esse comando precisa de acesso à internet e substitui qualquer cópia existente da biblioteca com esse nome de arquivo. Continue somente após o download ser concluído com sucesso:
curl -fsSLo videos/shaka-player.compiled.js https://cdn.jsdelivr.net/npm/shaka-player@5.2.12/dist/shaka-player.compiled.js
Salve esta página como videos/player.html. Ela segue a
configuração básica do Shaka e carrega os manifestos por caminhos
relativos à página, para que a mídia e o player compartilhem a mesma origem:
<!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>
No diretório que contém serve.go e videos, inicie o
servidor em primeiro plano:
go run serve.go -port 8080
Abra http://127.0.0.1:8080/videos/player.html em um navegador com suporte a H.264/AAC e Media Source
Extensions. Escolha um fluxo, clique em Load stream, aguarde
Ready. Press play. e use o controle de reprodução do
vídeo. Experimente as três opções e avance para perto do final. Pare o servidor com Ctrl+C. Se a
porta 8080 estiver ocupada, escolha outro valor para -port e use essa porta
na URL do navegador. Uma falha ao vincular o servidor ao endereço encerra o processo com um erro,
em vez de anunciar uma URL de acesso. A reprodução foi verificada com Shaka Player 5.2.12 e
Chromium 145.
Otimizar o desempenho com processamento concorrente
Comece com um único processo de conversão e meça o uso de CPU, memória e disco. Aumente a concorrência com um pool limitado de processos de trabalho, não com uma goroutine para cada upload na fila. Dê a cada tarefa seu próprio diretório de saída e informe cada falha à fila para permitir uma nova tentativa deliberada. O tempo limite do programa limita a duração de um processo FFmpeg; ele não substitui os limites do sistema operacional nem um sandbox.
Para mídias não confiáveis, use processos de trabalho isolados, sem credenciais nem acesso irrestrito à rede. Publique as saídas concluídas por meio de um servidor web ou CDN devidamente configurado, com HTTPS, regras de cache, controles de acesso quando necessário e CORS apenas para as origens dos players que você pretende aceitar. O servidor de desenvolvimento em loopback não é um serviço de streaming em produção.
Testar e depurar sua implementação de streaming
O player acima verifica a reprodução local. Ele não comprova que uma escala de taxas de bits tem boa qualidade visual nem que a alternância automática funciona bem em condições reais de rede. Revise o vídeo e o áudio decodificados, incluindo o final da gravação, e confira os diagnósticos do FFmpeg antes de publicar.
Uma entrada ausente, uma faixa de áudio ausente ou um processo FFmpeg com falha retorna um status diferente de zero. O executor remove o novo diretório de saída após uma falha na conversão e se recusa a sobrescrever um destino existente. O FFmpeg pode se recuperar de algumas entradas danificadas e ainda retornar sucesso; “Conversion complete” não é uma garantia de que a entrada estava intacta. Use arquivos de origem comprovadamente válidos e compare o conteúdo e a duração da saída com os da fonte.
No diretório do projeto, inspecione os fluxos DASH adaptativos e decodifique todos os fluxos de cada saída concluída. Caminhos absolutos para os manifestos evitam ambiguidades quando o demultiplexador DASH resolve os nomes dos arquivos de 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"
)
O JSON deve listar vídeo H.264 com alturas de 180 e 360 pixels, além de áudio AAC. A largura depende
da proporção de tela da sua fonte. Compare nb_read_frames
nas duas representações de vídeo: a mesma entrada e a normalização da taxa de quadros devem
produzir a mesma contagem. Um fragmento final danificado pode encurtar silenciosamente uma
representação, mesmo quando o FFmpeg retorna zero sem um diagnóstico de erro. Uma contagem desigual
exige investigação, não publicação. Revise também a imagem e o áudio finais comparando-os com a
fonte; contagens iguais, por si só, não comprovam que o conteúdo está intacto. As verificações do
decodificador não emitem mensagens em caso de sucesso e interrompem a cadeia se um processo falhar
ou houver um diagnóstico de erro, mesmo que o FFmpeg se recupere e retorne zero. Ainda assim, elas
não comprovam que a gravação original estava intacta nem que todos os quadros esperados foram
preservados. O preenchimento do AAC pode deixar o áudio um pouco mais longo que o vídeo, portanto
compare o conteúdo decodificado e os timestamps, em vez de confiar apenas em uma duração
arredondada do manifesto.
Verifique também se cada segmento de inicialização e de mídia referenciado existe, tem o tipo MIME
esperado e retorna uma resposta de sucesso. Para adaptive-dash, inspecione o MPD
para confirmar a presença de duas representações de vídeo, em vez de inferir a adaptação pelo nome
de um arquivo. Consulte as opções dos multiplexadores DASH e HLS do FFmpeg
para saber mais sobre o empacotamento.
Conclusão
Agora você tem um fluxo HLS local, um fluxo DASH com um único vídeo e um fluxo DASH com duas representações de vídeo. Mantenha cada manifesto junto de seus segmentos ao passar desta verificação em loopback para seu ambiente de publicação.
Se você procura uma solução gerenciada, o Robot 🤖 /video/adaptive da Transloadit cuida do empacotamento HLS e MPEG-DASH. Consulte nosso go-sdk para integração com Go.
