Streaming vidéo adaptatif en Go avec FFmpeg
Utilisez Go pour exécuter FFmpeg, empaqueter une vidéo locale en HLS ou MPEG-DASH et lire les fichiers terminés dans un navigateur. L’exemple crée aussi un flux DASH à deux représentations. Ce tutoriel porte sur l’empaquetage et la lecture en local ; il ne s’agit ni d’un service de diffusion de production, ni d’une démonstration de l’adaptation à des conditions réseau changeantes.
Introduction au streaming adaptatif : HLS ou MPEG-DASH
Le streaming à débit adaptatif ajuste dynamiquement la qualité vidéo en fonction des conditions
réseau du spectateur. Deux standards répandus sont HLS (développé
par Apple) et MPEG-DASH (un standard ouvert). HLS utilise des fichiers manifestes
.m3u8, tandis que MPEG-DASH utilise des fichiers .mpd.
Les deux formats découpent les vidéos en segments plus petits. Un lecteur a besoin de plusieurs
représentations compatibles pour changer de qualité ; découper un seul encodage en segments ne le
rend pas adaptatif.
Configuration de FFmpeg avec Go : choisir la bonne surcouche
Ce guide utilise le paquet standard os/exec de
Go et l’interface en ligne de commande documentée de FFmpeg.
Aucune surcouche Go n’est nécessaire. Utilisez Bash pour les commandes shell, avec Go, FFmpeg,
ffprobe et cURL disponibles dans votre PATH. La conversion a été testée avec
Go 1.26.8 et FFmpeg/ffprobe 9.0.1 sous Linux. Votre build de FFmpeg doit inclure les encodeurs
libx264 et AAC ainsi que les muxers HLS et DASH :
go version && ffmpeg -version && ffprobe -version && ffmpeg -encoders && ffmpeg -muxers
Travaillez dans un répertoire contenant une vidéo locale valide au format paysage nommée
input.mp4, avec une piste audio et une hauteur d’au moins 360 pixels.
Enregistrez les deux programmes Go sous les noms de fichiers indiqués ci-dessous et exécutez-les
séparément ; go run . combinerait leurs deux fonctions
main. Le convertisseur normalise la vidéo à 24 images par seconde et aligne
les images clés toutes les quatre secondes. Adaptez les dimensions et l’échelle de débits à votre
contenu source ; générer deux représentations ne constitue pas en soi une recommandation de qualité.
Conversion de vidéos au format HLS avec Go et FFmpeg
Enregistrez le programme complet suivant sous stream.go. Le même exécuteur prend
en charge HLS, DASH avec une seule vidéo et DASH avec deux représentations vidéo. Chaque appel exige
un nouveau répertoire de sortie, afin que les manifestes et les segments ne puissent pas entrer en
collision avec une tâche précédente. Le programme lance FFmpeg sans passer par 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")
}
Exécutez :
go run stream.go input.mp4 hls-output hls
Cela produit une playlist média VOD et des segments MPEG-TS avec un seul débit vidéo. Il s’agit de streaming segmenté, pas encore de streaming à débit adaptatif. N’exposez pas le répertoire de sortie aux spectateurs avant que la commande ait réussi.
Implémentation de la conversion MPEG-DASH en Go
Utilisez le même programme avec le mode dash :
go run stream.go input.mp4 dash-output dash
La sortie contient index.mpd, des segments d’initialisation et des segments
média. Conservez ensemble leurs noms de fichiers relatifs lors de la publication. Ce mode comporte
une seule représentation vidéo et un ensemble d’adaptation audio séparé.
Création de variantes à débit adaptatif
Le mode adaptive-dash mappe la vidéo d’entrée deux fois, met à l’échelle chaque
sortie et attribue des débits distincts. Définir -b:v:1 seul ne crée pas de
second flux vidéo ; le mappage supplémentaire est indispensable. Les deux représentations partagent
des images clés alignées et un même flux audio.
go run stream.go input.mp4 adaptive-output adaptive-dash
Utilisez une source d’au moins 360 pixels de haut pour cette échelle illustrative 180p/360p, et examinez le texte, le mouvement et le rapport d’aspect dans les deux qualités. Les échelles de débits de production devraient refléter votre contenu et vos appareils cibles. Le HLS multivariante nécessite en outre plusieurs playlists média et une playlist maître ; le mode HLS à variante unique ci-dessus ne les crée pas.
Création d’un serveur de streaming avec gestion des segments
Pour une vérification de lecture locale, placez uniquement des sorties de test terminées et
publiques dans un répertoire videos.
Une fois les trois conversions réussies, déplacez ensemble leurs répertoires de sortie :
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 un répertoire videos existe déjà, cette commande s’arrête sans remplacer
son contenu. Utilisez un nouveau répertoire de projet pour une autre réexécution complète plutôt que
de supprimer des données existantes.
Enregistrez ce programme distinct sous serve.go. Il se lie à l’interface de
bouclage et définit les types MIME appropriés.
Le http.FileServer de Go active ici l’affichage du
contenu des répertoires ; ne placez donc pas de fichiers privés dans le répertoire public.
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))
}
Téléchargez une version figée de Shaka Player dans le même répertoire public. Cette commande nécessite un accès à Internet et remplace toute copie existante de la bibliothèque portant ce nom de fichier. Ne continuez qu’une fois le téléchargement réussi :
curl -fsSLo videos/shaka-player.compiled.js https://cdn.jsdelivr.net/npm/shaka-player@5.2.12/dist/shaka-player.compiled.js
Enregistrez cette page sous videos/player.html. Elle suit la
configuration de base de Shaka et charge les manifestes relativement
à la page, de sorte que les médias et le lecteur partagent une même origine :
<!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>
Depuis le répertoire contenant serve.go et videos,
démarrez le serveur au premier plan :
go run serve.go -port 8080
Ouvrez http://127.0.0.1:8080/videos/player.html dans un navigateur prenant en charge H.264/AAC et Media Source
Extensions. Choisissez un flux, cliquez sur Load stream,
attendez le message Ready. Press play., puis utilisez la
commande de lecture de la vidéo. Essayez les trois entrées et positionnez la lecture près de la fin.
Arrêtez le serveur avec Ctrl+C. Si le port 8080 est occupé, choisissez une autre valeur
-port et utilisez ce port dans l’URL du navigateur. En cas d’échec de la
liaison, le programme se termine avec une erreur au lieu d’annoncer une URL de service. La lecture a
été vérifiée avec Shaka Player 5.2.12 et Chromium 145.
Optimisation des performances grâce au traitement concurrent
Commencez avec un seul worker de conversion et mesurez l’utilisation du processeur, de la mémoire et du disque. Augmentez la concurrence avec un pool de workers borné, et non avec une goroutine pour chaque téléversement en file d’attente. Attribuez à chaque tâche son propre répertoire de sortie et signalez chaque échec à la file d’attente, afin que la tâche puisse être relancée de manière délibérée. Le délai d’expiration du programme limite un processus FFmpeg ; il ne remplace ni les limites du système d’exploitation ni un bac à sable.
Pour les médias non fiables, utilisez des workers isolés, sans informations d’identification ni accès réseau illimité. Publiez les sorties terminées via un serveur web ou un CDN correctement configuré, avec HTTPS, des règles de cache, des contrôles d’accès si nécessaire, et CORS uniquement pour les origines de lecteur que vous comptez prendre en charge. Le serveur de développement en bouclage local n’est pas un service de streaming de production.
Test et débogage de votre implémentation de streaming
Le lecteur ci-dessus vérifie la lecture locale. Il ne prouve pas qu’une échelle de débits donne un bon rendu, ni que le basculement automatique fonctionne bien dans des conditions réseau réelles. Examinez la vidéo et l’audio décodés, y compris la fin de l’enregistrement, et vérifiez les diagnostics de FFmpeg avant de publier.
Une entrée manquante, une piste audio manquante ou un processus FFmpeg en échec renvoie un code de sortie non nul. L’exécuteur supprime le nouveau répertoire de sortie après un échec de conversion et refuse d’écraser une destination existante. FFmpeg peut récupérer certaines entrées endommagées et tout de même signaler un succès ; « Conversion complete » ne garantit pas que l’entrée était intacte. Utilisez des fichiers source dont vous savez qu’ils sont valides et comparez le contenu et la durée de la sortie à ceux de la source.
Depuis le répertoire du projet, inspectez les flux DASH adaptatifs et décodez tous les flux de chaque sortie terminée. Les chemins absolus vers les manifestes évitent toute ambiguïté lorsque le démultiplexeur DASH résout les noms de fichiers des segments :
(
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"
)
Le JSON devrait lister une vidéo H.264 avec des hauteurs de 180 et 360 pixels, ainsi qu’un audio
AAC. La largeur dépend du rapport d’aspect de votre source. Comparez nb_read_frames
pour les deux représentations vidéo : la même entrée et la même normalisation de la fréquence
d’images devraient produire le même nombre. Un fragment final endommagé peut raccourcir
silencieusement une représentation, même lorsque FFmpeg renvoie zéro sans diagnostic d’erreur. Un
nombre inégal nécessite une investigation, pas une publication. Examinez aussi l’image et l’audio
finaux par rapport à la source ; des nombres égaux ne prouvent pas à eux seuls que le contenu est
intact. Les vérifications du décodeur sont silencieuses en cas de succès et interrompent
l’enchaînement en cas d’échec d’un processus ou de diagnostic d’erreur, même si FFmpeg
récupère et renvoie zéro. Elles n’établissent toujours pas que l’enregistrement d’origine était
intact ni que chaque image attendue a été conservée. Le remplissage AAC peut rendre l’audio
légèrement plus long que la vidéo ; comparez donc le contenu décodé et les horodatages plutôt que de
vous fier uniquement à une durée de manifeste arrondie.
Vérifiez également que chaque segment d’initialisation et chaque segment média référencés existent,
possèdent le type MIME attendu et renvoient une réponse réussie. Pour adaptive-dash,
inspectez le MPD afin d’y trouver deux représentations vidéo plutôt que de déduire l’adaptation d’un
nom de fichier. Consultez les
options des muxers DASH et HLS de FFmpeg pour les détails de
l’empaquetage.
Conclusion
Vous disposez maintenant d’un flux HLS local, d’un flux DASH à vidéo unique et d’un flux DASH avec deux représentations vidéo. Conservez chaque manifeste avec ses segments lorsque vous passerez de cette vérification en bouclage local à votre configuration de publication.
Si vous cherchez une solution gérée, le Robot 🤖 /video/adaptive (English) de Transloadit prend en charge l’empaquetage HLS et MPEG-DASH. Consultez notre go-sdk pour l’intégration avec Go.
