Blog
Deep-dive technique19 juillet 2026

Décoder de la 4K en Rust

AquaTube n'utilise aucun lecteur système : la vidéo est décodée à la main, jusqu'à la 4K HEVC en matériel, pour passer dans l'écran cathodique. Le pipeline, ses pièges, et l'horloge qui donne le tempo.

Pourquoi décoder soi-même

AquaTube n'ouvre pas la vidéo dans un lecteur système ni dans une surface à part. L'image doit vivre dans l'écran cathodique du pod, passer par le même shader CRT que le reste : courbure, scanlines, phosphore. Une vidéo posée dans sa propre fenêtre ne pourrait pas recevoir ce traitement. Il fallait donc amener chaque image jusqu'à une texture que je contrôle.

Et une seule exigence a décidé du reste : lire de la 4K HEVC en décodage matériel, fluide. Cette contrainte, posée tôt, a écarté les moteurs qui ne décodent qu'en logiciel et a fixé la stack sur Rust plus FFmpeg. Je ne suis pas parti de "je veux faire du Rust" : je suis parti d'une exigence vidéo, et elle a choisi l'outil.

Le décodage matériel 4K a choisi la stack, pas l'inverse. Une seule exigence non négociable suffit à trancher un débat d'outillage.

Le binding et ses 7 DLL

Côté Rust, j'utilise `ffmpeg-the-third` (le fork maintenu pour FFmpeg 8, là où `ffmpeg-next` a décroché), lié à un build partagé de FFmpeg 8.1. La compilation n'est pas gratuite : elle génère les liaisons via bindgen, donc elle exige LLVM/libclang et deux variables d'environnement pour trouver les en-têtes et les libs.

Le piège n'est pas à la compilation, il est à la distribution. L'exe a besoin des DLL FFmpeg à côté de lui. J'en avais mis cinq (avcodec, avformat, avutil, swscale, swresample), les seules que j'utilise consciemment. L'app refusait de démarrer : `0xC0000135`, DLL introuvable. Il en manquait deux, avfilter et avdevice, que le binding référence au chargement même sans que je m'en serve. Un code parfait ne démarre pas s'il manque une dépendance de chargement, et l'erreur ne dit pas laquelle.

Le software plafonne, le matériel prend le relais

En décodage logiciel, tout n'est pas égal. Le 1080p passe large, autour de 175 à 189 images par seconde sur ma machine. Mais la 4K HEVC tombe à 47 images par seconde, sous la barre des 60. En logiciel, la 4K fluide n'est pas tenable.

Pour la tenir, il faut le décodeur matériel du GPU, exposé par FFmpeg sous les décodeurs `cuvid`. On choisit le décodeur selon le codec, avec repli logiciel si le matériel n'est pas là :

Vient alors une subtilité qui m'a coûté un moment : un décodeur matériel ne révèle son format de sortie (du NV12) qu'après avoir décodé la première image. Impossible donc de préparer le convertisseur NV12 vers RGBA à l'avance. Il faut le créer paresseusement, une fois la première frame arrivée :

// cuvid ne révèle son format (NV12) qu'après la première image.
// On crée le convertisseur NV12 -> RGBA à la 1re frame, pas avant.
if self.scaler.is_none() {
    self.scaler = Some(Scaler::get(
        frame.format(), frame.width(), frame.height(), // format connu MAINTENANT
        Pixel::RGBA, out_w, out_h, Flags::BILINEAR,
    )?);
}

Qui donne le tempo

Décoder ne suffit pas : il faut synchroniser le son et l'image, sinon ils dérivent. Et la synchronisation n'est pas un algorithme malin. C'est le choix d'une horloge maître, auquel tout le reste se plie.

Ici, l'audio mène. Le thread audio compte les frames réellement jouées par la carte son ; c'est le temps de référence. La vidéo n'affiche une image que lorsque son temps de présentation est atteint selon cette horloge :

Reste le cas où le maître disparaît : une vidéo sans piste audio, ou pas de périphérique de sortie. Plus d'horloge audio, donc la vidéo se fige. La parade est une horloge murale de repli qui prend le relais quand l'audio n'est pas là, pour que l'image continue d'avancer.

// L'audio est l'horloge maître : il compte les frames réellement jouées.
// La vidéo ne sort une image que quand son temps est venu selon cette horloge.
fn next_due(&mut self, audio_played_secs: f64) -> Option<VFrame> {
    if self.head_pts <= audio_played_secs { self.pop() } else { None }
}

Ce que je retiens

Trois choses. La première : une exigence non négociable, posée tôt, est un couperet utile. La 4K matérielle a tranché en une phrase un débat de stack qui aurait pu traîner des semaines.

La deuxième : le décodage matériel impose son ordre. Il ne dévoile son format qu'à l'usage, donc on construit le pipeline après lui, pas avant. Vouloir tout préparer d'avance, c'est se battre contre un composant qui ne parle qu'une fois lancé.

La troisième : synchroniser, c'est décider qui est maître, pas écrire une formule. Une fois le maître nommé, tout s'aligne dessus. Et le jour où il peut disparaître, on lui prévoit un remplaçant, sinon le temps s'arrête avec lui.

Synchroniser, c'est décider qui est maître, pas écrire une formule.