Player vidéo JS custom avec streaming ffmpeg en PHP

Le premier vrai test de ShareBox avec un fichier réel, c'était un MKV de 8 Go — encode HEVC, audio DTS, sous-titres PGS gravés depuis un Blu-ray japonais. Le <video src="..."> que j'avais mis en place s'est ouvert, a tourné deux secondes, et s'est arrêté en silence total. Pas d'événement error, pas de message dans la console. Juste une roue qui tourne indéfiniment.

ShareBox était parti d'un besoin simple — partager des fichiers lourds sans cloud tiers. Le streaming vidéo était censé être une feature parmi d'autres. Sauf que les vrais fichiers ne sont pas des MP4 H.264/AAC propres : il y a des MKV, des HEVC, des pistes Dolby, des sous-titres bitmap. Le player natif du navigateur abandonne sans un mot. J'ai donc construit un player complet : côté serveur, download.php orchestre trois modes de streaming ffmpeg. Côté client, une machine à états JS gère la sélection du mode, les relances sur blocage, le burn-in de sous-titres image et l'UX. Ce post documente ce que j'ai construit — et les endroits où ça m'a coûté plus de temps que prévu.

Les trois modes de streaming côté PHP

Le point d'entrée est download.php?stream=MODE. Trois modes sont disponibles, sélectionnés dynamiquement par le JS en fonction du codec détecté.

native — X-Accel-Redirect

Pour les fichiers déjà lisibles par le navigateur (MP4 H.264 + AAC), on délègue à nginx via X-Accel-Redirect. PHP ne touche pas les octets, pas de ffmpeg, pas de charge CPU. Le byte-range HTTP fonctionne normalement, le seek est instantané.

remux — repackaging à coût zéro

Un MKV H.264 ne peut pas être streamé nativement par le navigateur, mais les codecs sont compatibles. La solution : repackager à la volée en MP4 fragmenté sans réencoder.

ffmpeg -i input.mkv \
  -c:v copy -c:a aac \
  -movflags frag_keyframe+empty_moov+default_base_moof \
  -min_frag_duration 300000 \
  -f mp4 pipe:1

-c:v copy : zéro réencodage vidéo. -c:a aac : l'audio est converti si nécessaire (DTS, AC3 → AAC). Le résultat est un MP4 fragmenté streamé sur stdout, que PHP relaie directement dans la réponse HTTP. Coût CPU : quasi nul pour la vidéo, quelques pourcents pour la conversion audio.

Les flags frag_keyframe+empty_moov+default_base_moof sont critiques. Sans empty_moov, le navigateur attend la fin du fichier pour lire le moov atom (les métadonnées) — ce qui bloque indéfiniment un pipe. Sans frag_keyframe, les fragments ne commencent pas sur des keyframes et le seek casse.

Un piège sur lequel j'ai perdu une heure : j'avais ajouté -fflags +genpts et first_pts=0 pour normaliser les timestamps. L'idée paraissait saine — certains MKV ont des PTS qui ne commencent pas à zéro, et je voulais éviter des surprises au seek. En pratique, ces options modifient les PTS d'une façon que le navigateur interprète mal sur un flux fragmenté : la vidéo et l'audio se désynchronisent progressivement après chaque seek. Supprimés, le problème a disparu immédiatement.

Normaliser les timestamps sur un pipe fragmenté revient à "corriger" quelque chose que le navigateur sait gérer seul — et à introduire une désynchronisation qu'il ne sait pas corriger.

transcode — pour les codecs incompatibles

HEVC, VP9, AV1 non supportés, ou audio Dolby qui ne passe pas : on réencode.

ffmpeg -i input.mkv \
  -c:v libx264 -preset ultrafast -crf 23 \
  -c:a aac \
  -movflags frag_keyframe+empty_moov+default_base_moof \
  -min_frag_duration 300000 \
  -f mp4 pipe:1

-preset ultrafast sacrifie la compression pour minimiser la latence de démarrage. -crf 23 donne une qualité acceptable sans exploser le débit. La vidéo est regardable en quelques secondes, pas après un encodage complet.

Sémaphore de concurrence et déconnexion propre

Chaque processus ffmpeg consomme du CPU pendant toute la durée du stream. Sans limite, dix téléchargements simultanés = dix ffmpeg qui se battent sur les cœurs. J'ai mis un sémaphore PHP (via sem_acquire) limité à 4 processus concurrents.

$sem = sem_get(ftok(__FILE__, 'f'), 4);
sem_acquire($sem);

register_shutdown_function(function () use ($sem, $proc) {
    if (is_resource($proc)) {
        proc_terminate($proc);
    }
    sem_release($sem);
});

// Streaming loop
while (!feof($stdout) && !connection_aborted()) {
    echo fread($stdout, 65536);
    flush();
}

Le connection_aborted() dans la boucle de lecture est essentiel : quand le client ferme l'onglet, PHP détecte la déconnexion, sort de la boucle, la shutdown function tue le processus ffmpeg et libère le slot du sémaphore. Sans ça, les ffmpeg orphelins s'accumulent jusqu'au reboot.

Autre ajustement infrastructure : pm.max_children dans PHP-FPM est passé de 5 à 25. Chaque stream actif monopolise un worker FPM pendant toute sa durée — c'est inévitable avec du streaming synchrone. Avec 5 workers, le 6ème visiteur attend dans le noir.

Cache ffprobe SQLite

Avant de choisir le mode de streaming, le JS a besoin de connaître le codec de la vidéo. ffprobe répond à la question, mais prend 2 à 12 secondes sur un fichier distant (accès disque, parsing des headers). Ma première version faisait de la détection par essai-erreur côté JS : tentative native, timeout de 2 secondes, si rien ne joue → remux. En pratique, 2s c'est long pour l'utilisateur, et ça génère des faux positifs sur les connexions lentes. La solution correcte : connaître le codec avant de lancer quoi que ce soit.

Cache SQLite indexé par (path, mtime). La clé inclut le mtime du fichier pour invalider automatiquement le cache si le fichier est remplacé. Hit de cache : ~100ms. Cold : 2-12s (une seule fois par fichier).

function probeVideo(string $path): array {
    $db  = new PDO('sqlite:' . PROBE_CACHE_DB);
    $key = hash('xxh64', $path . '|' . filemtime($path));

    $row = $db->query(
        "SELECT data FROM probe_cache WHERE key = " . $db->quote($key)
    )->fetch();

    if ($row) {
        return json_decode($row['data'], true);
    }

    $cmd    = 'ffprobe -v quiet -print_format json -show_streams ' . escapeshellarg($path);
    $result = json_decode(shell_exec($cmd), true);

    $db->prepare("INSERT OR REPLACE INTO probe_cache (key, data) VALUES (?, ?)")
       ->execute([$key, json_encode($result)]);

    return $result;
}

La machine à états JS

Le player côté client repose sur un objet S (state) qui évolue au fil des événements vidéo. Pas de framework, pas de gestionnaire d'état externe — juste un objet mutable surveillé par des écouteurs d'événements ciblés.

const S = {
    step:        'native',   // mode actuellement testé
    confirmed:   null,       // mode confirmé (ne plus retester au seek)
    stallCount:  0,          // compteur de relances watchdog
    seekPending: false,      // seek en cours (debounce)
};

Sélection du mode par ffprobe

La fonction chooseModeFromProbe(probe) analyse les streams retournés par ffprobe et décide du mode :

function chooseModeFromProbe(streams) {
    const video   = streams.find(s => s.codec_type === 'video');
    const audio   = streams.find(s => s.codec_type === 'audio');
    const vcodec  = video?.codec_name;
    const acodec  = audio?.codec_name;
    const container = currentFile.ext.toLowerCase();

    if (vcodec === 'h264' && container === 'mkv') return 'remux';
    if (vcodec === 'h264' && container === 'mp4' && acodec === 'aac') return 'native';
    if (vcodec === 'h264' && container === 'mp4') return 'transcode';

    // VP9, AV1, HEVC : tenter native si le navigateur déclare le support
    const probe = document.createElement('video').canPlayType(mimeFor(vcodec));
    return probe !== '' ? 'native' : 'transcode';
}

Détection silencieuse des échecs HEVC

Safari et Chrome sur Windows déclarent parfois supporter HEVC via canPlayType(). Quand le décodage hardware échoue sur ces navigateurs, il le fait dans un silence parfait : aucun événement error, aucun stalled, aucun message console. L'audio démarre normalement. L'image reste figée sur la première frame — ou pire, sur un écran noir. video.videoWidth reste à 0, et c'est le seul indice disponible.

setTimeout(() => {
    if (video.videoWidth === 0 && S.step === 'native') {
        console.warn('Silent HEVC failure → cascade to transcode');
        S.step = 'transcode';
        reloadStream();
    }
}, 1500);
Un codec qui "supporte" un format mais échoue à décoder en silence est exactement aussi utile qu'un codec qui ne supporte pas le format — sauf qu'il est beaucoup plus difficile à détecter.

Watchdog avec backoff exponentiel

Ma première implémentation du watchdog utilisait un timeout fixe par mode. Le problème est apparu immédiatement sur les gros fichiers HEVC : ffmpeg prend plusieurs secondes pour démarrer un transcode (parsing, allocation mémoire, premier keyframe à émettre). Le watchdog s'impatientait, relançait le stream. Deux processus ffmpeg concurrents démarraient, se disputaient les cœurs, ralentissaient mutuellement. Le sémaphore saturait au troisième retry. L'utilisateur voyait une roue qui tournait indéfiniment, sans aucune explication. Et pendant ce temps, côté serveur, plusieurs ffmpeg zombies attendaient leurs 4 slots.

Le backoff exponentiel par mode a réglé ça : le premier retry attend 20s en transcode, le deuxième 40s, plafonné à 120s. Un ffmpeg légitimement lent au démarrage a le temps de produire ses premiers fragments avant que le watchdog ne panique.

const BASE_TIMEOUTS = { native: 5, remux: 10, transcode: 20, burnSub: 30 };

function startWatchdog() {
    clearTimeout(stallTimer);
    const base    = BASE_TIMEOUTS[S.confirmed ?? S.step] ?? 15;
    const timeout = Math.min(base * Math.pow(2, S.stallCount), 120) * 1000;

    stallTimer = setTimeout(() => {
        S.stallCount++;
        console.warn(`Stall #${S.stallCount}, retrying from ${video.currentTime}s`);
        reloadStream(video.currentTime);
    }, timeout);
}

video.addEventListener('timeupdate', () => startWatchdog());
video.addEventListener('waiting',    () => startWatchdog());

Le watchdog se réinitialise à chaque timeupdate (lecture active). S'il expire, on relance le stream depuis la position courante. Le stallCount double le délai à chaque retry, plafonné à 120s.

Sous-titres : texte et image

Sous-titres texte (SRT/ASS) en WebVTT

Les sous-titres texte sont extraits par ffmpeg en WebVTT à la demande et servis comme texte brut. Côté JS, je n'utilise pas la balise <track> native : le positionnement est trop limité et le rendu ASS est inexistant. J'ai un overlay <div> absolu positionné via getBoundingClientRect et un décalage pour la barre de contrôles.

Pour les fichiers avec des milliers de cues, la recherche de la cue active à chaque timeupdate serait O(n). J'utilise une recherche dichotomique initiale, puis un pointeur courant qui avance linéairement — O(log n) au premier seek, O(1) ensuite.

function findCueIndex(cues, time) {
    let lo = 0, hi = cues.length - 1;
    while (lo < hi) {
        const mid = (lo + hi) >> 1;
        if (cues[mid].end < time) lo = mid + 1;
        else hi = mid;
    }
    return lo;
}

let cuePtr = 0;

video.addEventListener('timeupdate', () => {
    const t = video.currentTime;
    while (cuePtr < cues.length - 1 && cues[cuePtr].end < t) cuePtr++;
    if (cues[cuePtr].start > t + 1) cuePtr = findCueIndex(cues, t);

    const cue = cues[cuePtr];
    subOverlay.textContent = (cue.start <= t && t <= cue.end) ? cue.text : '';
});

La taille de police est proportionnelle à la largeur vidéo (2,5%, min 13px), recalculée par un ResizeObserver sur le conteneur.

Sous-titres image (PGS/VOBSUB) — burn-in

Les sous-titres PGS (Blu-ray) et VOBSUB (DVD) sont des images bitmap : impossible de les overlayer en CSS. La seule solution : les incruster directement dans la vidéo via ffmpeg, ce qui déclenche un transcode complet avec le filtre subtitles.

Ça m'a coûté une demi-journée. Sur mes fichiers de test — des extraits que j'avais préparés moi-même — le filtre subtitles fonctionnait parfaitement. Puis j'ai testé sur les vrais fichiers Blu-ray : sous-titres décalés, coupés, parfois absents. Le problème : les dimensions du canvas PGS ne correspondent pas toujours aux dimensions de la vidéo. Un MKV Blu-ray 1080p peut avoir un canvas de sous-titres en 1920×1080, mais aussi en 1920×816 ou n'importe quelle valeur héritée du mastering original. Un filtre subtitles standard étire ou coupe sans prévenir. La solution, trouvée après un moment dans un thread ffmpeg-user de 2019, est scale2ref : adapter le canvas de sous-titres à la résolution de la vidéo avant l'overlay.

ffmpeg -i input.mkv \
  -filter_complex \
    "[0:v][0:s:0]scale2ref[vid][sub]; \
     [sub]scale=iw:ih[sub2]; \
     [vid][sub2]overlay" \
  -c:v libx264 -preset ultrafast -crf 23 \
  -c:a aac \
  -movflags frag_keyframe+empty_moov+default_base_moof \
  -f mp4 pipe:1

Côté JS, la détection d'un sous-titre image déclenche S.step = 'burnSub' avec l'index de la piste (burnSub=N dans l'URL). Le timeout watchdog monte à 30s pour cette combinaison — le démarrage du transcode avec sous-titres intégrés est plus lent qu'un transcode simple.

UX : contrôles, badge de mode, plein écran

Quelques décisions d'UX méritent d'être explicitées. Le badge de mode (vert = REMUX, orange = TRANSCODE, gris = NATIF) est cliquable pour cycler manuellement entre les modes. C'est d'abord un outil de débogage — quand le remux produit un artefact audio ou que le transcode est inexplicablement lent, un clic permet de forcer l'autre mode sans recharger la page. En pratique, les utilisateurs finaux s'en servent aussi quand quelque chose ne marche pas.

Le plein écran est déclenché sur .player-card (la div parente), pas sur <video> directement. Appeler element.requestFullscreen() sur <video> amène le navigateur à afficher ses propres contrôles par-dessus les contrôles custom — ce qui sur Firefox donne un résultat particulièrement chaotique. Sur la div parente, les contrôles custom restent visibles et fonctionnels. L'auto-hide se déclenche après 3s d'inactivité souris, avec le curseur caché. Un tap = play/pause, double tap = plein écran, avec un debounce de 250ms pour distinguer les deux. Les raccourcis clavier (Espace/K, ←/→ ±10s, F, M) sont mappés sur les mêmes actions.

Détail iOS qui vaut son pesant de débogage : Accept-Ranges: none dans les headers PHP pour les modes remux et transcode. Safari sur iOS envoie des requêtes Range sur les éléments <video>, même quand la source est un pipe. Sans cet header, Safari tente un byte-range sur un flux non-seekable et obtient une réponse incorrecte — résultat : lecture cassée dès la première seconde. Volume, mute et vitesse de lecture sont persistés en localStorage entre les sessions. Le throttle requestAnimationFrame sur timeupdate évite d'empiler des mises à jour de barre de progression à 60 fps quand la lecture est active.

Conclusion

Un player vidéo "qui marche vraiment" sur un catalogue hétérogène de fichiers, c'est beaucoup plus de surface que prévu. L'essentiel du code n'est pas dans le player lui-même — c'est dans la gestion des cas limites : codecs silencieux, sous-titres bitmap, processus orphelins, iOS qui fait du range sur un pipe.

Si c'était à refaire : implémenter la sonde ffprobe + le cache SQLite en premier, avant même d'écrire une ligne de JS. Avoir la vérité codec disponible en 100ms change complètement la logique de sélection de mode et simplifie tout le reste. La machine à états, le watchdog, le burn-in de sous-titres — tout ça devient prévisible une fois qu'on sait exactement à quoi on a affaire avant de démarrer.

Le code source complet est sur GitHub (ohugonnot/sharebox).

Commentaires (0)