Introduction
Dans le précédent article, on a posé les bases d’un POC de streaming vidéo… qui ressemblait déjà plus à une vraie plateforme qu’à une simple démo. Upload automatisé, pipeline asynchrone avec Symfony Messenger et RabbitMQ, workers dédiés grâce à FrankenPHP, compression vidéo intégrée : bref, un socle solide, scalable et fluide.
Mais qui dit vidéo dit aussi poids, bande passante et surtout diffusion intelligente. C’est là qu’entre en scène FFmpegpour générer du HLS multi-résolutions (480p, 720p, 1080p…) et offrir une lecture adaptative, façon Netflix ou YouTube.
Et comme un POC sans un peu de sécurité serait incomplet, on ira un cran plus loin : on verra comment mettre en place un chiffrement simple avec AES-128 pour illustrer le concept, puis on expliquera en quoi ce n’est pas un vrai DRM. Enfin, on amorcera la discussion autour des DRM multi-systèmes (CMAF, Widevine, FairPlay), utilisés en production par les grandes plateformes.
La stack reste la même — Symfony 7, FrankenPHP, Docker, RabbitMQ, Varnish, PostgreSQL — mais l’article sera beaucoup plus spécifique et technique que le précédent.
Prêt à continuer notre petit “Netflix maison” en PHP ?
DRM, quésako ?
Le terme DRM (Digital Rights Management) peut paraître un peu abstrait, mais il désigne tout simplement l’ensemble des mécanismes qui permettent de protéger les droits d’une œuvre numérique (vidéo, musique, ebook…).
Concrètement, les DRM empêchent l’utilisateur de copier, redistribuer ou enregistrer un contenu protégé. Un exemple classique :
- Vous regardez une série sur Netflix depuis un iPhone.
- Vous trouvez une scène drôle et vous lancez un enregistrement d’écran.
- Résultat ? Vous obtenez un écran noir.
👉 C’est le DRM qui empêche la capture et protège la vidéo.
Le but ici n’est pas de devenir expert en cryptographie, mais de comprendre les rouages essentiels de ce système complexe :
- Comment une vidéo peut être chiffrée au moment de son encodage,
- Comment le player obtient la clé pour la déchiffrer,
- Et comment cela s’intègre dans notre stack Symfony/FrankenPHP.
Cet article reste pédagogique : l’idée est d’expliquer simplement, avec des commandes FFmpeg concrètes, comment on peut ajouter une première couche de protection à notre mini-Netflix maison.
HLS : le Graal du streaming adaptatif
Maintenant que l’on a vu la logique de base — encoder une vidéo en plusieurs formats (480p, 720p, 1080p) puis choisir lequel lire — on comprend vite les limites :
- soit on force l’utilisateur à choisir sa résolution,
- soit on impose une qualité unique, qui risque d’être trop lourde pour certains ou trop pauvre pour d’autres.
Imaginez plutôt : vous cliquez sur “Play” et c’est le lecteur qui ajuste la qualité automatiquement en fonction de votre bande passante et de la taille de votre écran.
Résultat :
- une vidéo fluide sur une connexion 4G fragile,
- une version HD qui s’active directement si votre fibre suit la cadence,
- le tout sans que vous ayez à toucher au moindre réglage.
C’est exactement ce que permet HLS (HTTP Live Streaming).
Développé par Apple, HLS est devenu la norme de facto pour le streaming vidéo adaptatif. C’est la technologie derrière Netflix, YouTube, Twitch et la plupart des plateformes modernes. Son principe est simple et génial à la fois :
- la vidéo est découpée en petits segments (quelques secondes chacun),
- plusieurs versions de la vidéo existent, avec des débits différents,
- une playlist maître (
.m3u8
) décrit toutes les variantes, - le player choisit dynamiquement la bonne qualité en fonction de votre débit réseau.
👉 Avec HLS, on n’envoie jamais plus de données que nécessaire. C’est ce qui rend la lecture fluide, économique en bande passante et universelle.
Qu’est-ce qui change ?
Avant, on faisait de la multi‑compression “fichier par fichier” : une vidéo uploadée → plusieurs fichiers MP4 (480p/720p/1080p) → le player choisit manuellement.
Désormais, on passe au HLS adaptatif : on encode plusieurs renditions et on segment le flux en petits morceaux, avec une master playlist qui laisse le player choisir automatiquement la bonne qualité selon le débit.
Côté back‑office (EasyAdmin), rien ne change pour l’admin :
- upload,
-
Messenger
dispatch, - RabbitMQ place la tâche,
- Le worker traite.
Ce qui change, c’est le travail du worker : on ne produit plus seulement des MP4 ; on génère des segments HLSet une playlist maître, et (optionnel) on chiffre la rendition pour la démo “DRM light”.
<?php
namespace App\Service;
use App\Entity\Videos;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
class VideoProcessingService
{
private const RESOLUTIONS = [
'480p' => ['width' => 854, 'height' => 480, 'bitrate' => '1000k'],
'720p' => ['width' => 1280, 'height' => 720, 'bitrate' => '2500k'],
'1080p' => ['width' => 1920, 'height' => 1080, 'bitrate' => '5000k'],
];
private const HLS_SEGMENT_DURATION = 6;
public function __construct(
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
private Filesystem $filesystem,
private string $projectDir
) {
}
private function getVideoStoragePath(): string
{
return $this->projectDir . '/public/videos';
}
public function processVideo(Videos $video, string $originalFilePath): void
{
try {
$this->logger->info('Starting video processing', ['video_id' => $video->getId()]);
$video->setStatus('processing');
$this->entityManager->flush();
$videoInfo = $this->extractVideoInfo($originalFilePath);
$video->setDuration($videoInfo['duration']);
$video->setMimeType($videoInfo['format']);
$video->setFileSize(filesize($originalFilePath));
$basePath = $this->getVideoStoragePath() . '/' . $video->getId();
$this->filesystem->mkdir($basePath);
$originalNewPath = $basePath . '/original.' . pathinfo($originalFilePath, PATHINFO_EXTENSION);
$this->filesystem->copy($originalFilePath, $originalNewPath);
$video->setOriginalPath($originalNewPath);
$resolutions = [];
$hlsPlaylists = [];
foreach (self::RESOLUTIONS as $quality => $config) {
$outputPath = $basePath . '/' . $quality . '.mp4';
if ($this->createResolution($originalFilePath, $outputPath, $config)) {
$resolutions[$quality] = $outputPath;
$this->logger->info("Created resolution: $quality", ['output_path' => $outputPath]);
$hlsPath = $this->createHLSSegments($outputPath, $basePath, $quality, $video->getId());
if ($hlsPath) {
$hlsPlaylists[$quality] = $hlsPath;
$this->logger->info("Created HLS for resolution: $quality", ['hls_path' => $hlsPath]);
}
} else {
$this->logger->error("Failed to create resolution: $quality");
}
}
$video->setResolutions($resolutions);
if (!empty($hlsPlaylists)) {
$masterPlaylistPath = $this->createMasterPlaylist($basePath, $hlsPlaylists);
$video->addResolution('hls_master', $masterPlaylistPath);
}
$video->setStatus('completed');
$video->setUpdatedAt(new \DateTimeImmutable());
$this->logger->info('Video processing completed', ['video_id' => $video->getId()]);
} catch (\Exception $e) {
$video->setStatus('failed');
$video->setUpdatedAt(new \DateTimeImmutable());
$this->logger->error('Video processing failed', [
'video_id' => $video->getId(),
'error' => $e->getMessage()
]);
}
$this->entityManager->flush();
}
private function extractVideoInfo(string $filePath): array
{
$command = [
'ffprobe',
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
$filePath
];
$process = new Process($command);
$process->run();
if (!$process->isSuccessful()) {
throw new \RuntimeException('Failed to extract video info: ' . $process->getErrorOutput());
}
$data = json_decode($process->getOutput(), true);
$videoStream = null;
foreach ($data['streams'] as $stream) {
if ($stream['codec_type'] === 'video') {
$videoStream = $stream;
break;
}
}
return [
'duration' => (int) round((float) $data['format']['duration']),
'format' => $data['format']['format_name'] ?? 'unknown',
'width' => $videoStream['width'] ?? 0,
'height' => $videoStream['height'] ?? 0,
];
}
private function createResolution(string $inputPath, string $outputPath, array $config): bool
{
$command = [
'ffmpeg',
'-i', $inputPath,
'-vf', sprintf('scale=%d:%d', $config['width'], $config['height']),
'-c:v', 'libx264',
'-b:v', $config['bitrate'],
'-c:a', 'aac',
'-b:a', '128k',
'-preset', 'fast',
'-y',
$outputPath
];
$process = new Process($command);
$process->setTimeout(3600); // 1 hour timeout
$process->run();
if (!$process->isSuccessful()) {
$this->logger->error('FFmpeg failed', [
'command' => implode(' ', $command),
'error' => $process->getErrorOutput()
]);
return false;
}
return true;
}
public function getVideoUrl(Videos $video, string $quality = 'original'): ?string
{
if ($quality === 'original') {
return $video->getOriginalPath() ? '/videos/' . $video->getId() . '/original.' . pathinfo($video->getOriginalPath(), PATHINFO_EXTENSION) : null;
}
$resolutions = $video->getResolutions();
if (!$resolutions || !isset($resolutions[$quality])) {
return null;
}
return '/videos/' . $video->getId() . '/' . $quality . '.mp4';
}
private function createHLSSegments(string $inputPath, string $basePath, string $quality, int $videoId): ?string
{
$hlsDir = $basePath . '/hls_' . $quality;
$this->filesystem->mkdir($hlsDir);
$keyFile = $hlsDir . '/encryption.key';
$keyInfoFile = $hlsDir . '/encryption.keyinfo';
$playlistFile = $hlsDir . '/playlist.m3u8';
$this->generateEncryptionKey($keyFile, $keyInfoFile, $videoId, $quality);
$command = [
'ffmpeg',
'-i', $inputPath,
'-c:v', 'libx264',
'-c:a', 'aac',
'-hls_time', (string)self::HLS_SEGMENT_DURATION,
'-hls_playlist_type', 'vod',
'-hls_segment_filename', $hlsDir . '/segment_%03d.ts',
'-hls_key_info_file', $keyInfoFile,
'-hls_flags', 'single_file',
'-y',
$playlistFile
];
$process = new Process($command);
$process->setTimeout(3600);
$process->run();
if (!$process->isSuccessful()) {
$this->logger->error('HLS creation failed', [
'command' => implode(' ', $command),
'error' => $process->getErrorOutput(),
'quality' => $quality
]);
return null;
}
return $playlistFile;
}
private function generateEncryptionKey(string $keyFile, string $keyInfoFile, int $videoId, string $quality): void
{
$encryptionKey = random_bytes(16);
file_put_contents($keyFile, $encryptionKey);
$keyUri = sprintf('/api/video/%d/key/%s', $videoId, $quality);
$keyInfoContent = sprintf("%s\n%s\n%s", $keyUri, $keyFile, bin2hex($encryptionKey));
file_put_contents($keyInfoFile, $keyInfoContent);
}
private function createMasterPlaylist(string $basePath, array $hlsPlaylists): string
{
$masterPlaylistPath = $basePath . '/master.m3u8';
$content = "#EXTM3U\n#EXT-X-VERSION:6\n";
foreach (self::RESOLUTIONS as $quality => $config) {
if (isset($hlsPlaylists[$quality])) {
$bandwidth = (int) str_replace('k', '000', $config['bitrate']);
$content .= sprintf(
"#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d\n",
$bandwidth,
$config['width'],
$config['height']
);
$content .= "hls_$quality/playlist.m3u8\n";
}
}
file_put_contents($masterPlaylistPath, $content);
return $masterPlaylistPath;
}
public function getEncryptionKey(int $videoId, string $quality): ?string
{
$keyFile = $this->getVideoStoragePath() . '/' . $videoId . '/hls_' . $quality . '/encryption.key';
if (!file_exists($keyFile)) {
return null;
}
return file_get_contents($keyFile);
}
public function getHLSUrl(Videos $video, string $quality = 'master'): ?string
{
if ($quality === 'master') {
$resolutions = $video->getResolutions();
if (isset($resolutions['hls_master'])) {
return '/videos/' . $video->getId() . '/master.m3u8';
}
return null;
}
$hlsPath = $this->getVideoStoragePath() . '/' . $video->getId() . '/hls_' . $quality . '/playlist.m3u8';
if (file_exists($hlsPath)) {
return '/videos/' . $video->getId() . '/hls_' . $quality . '/playlist.m3u8';
}
return null;
}
}
Explications du VideoProcessingService
Ce service est le cœur du pipeline vidéo : il prend un fichier brut uploadé et le transforme automatiquement en un ensemble de renditions multi-résolutions prêtes à être diffusées en HLS.
Pipeline complet
Le flux de traitement est le suivant :
- processVideo() : point d’entrée, marque la vidéo comme “processing”, extrait les infos (durée, format, taille), crée l’arborescence de stockage et lance les étapes suivantes.
- createResolution() : génère les rendus en 480p, 720p et 1080p grâce à FFmpeg.
- createHLSSegments() : segmente chaque rendu en fichiers
.ts
de 6 secondes et génère la playlist.m3u8
, avec un chiffrement AES-128 et une clé unique par qualité. - createMasterPlaylist() : assemble une playlist principale
master.m3u8
qui référence toutes les résolutions disponibles. - Finalisation : met à jour la base de données avec les chemins des fichiers, passe le statut en “completed” et logge le résultat.
Ce que le service gère automatiquement
- ✅ Transcodage multi-résolutions (480p / 720p / 1080p)
- ✅ Segmentation HLS avec chunks de 6 secondes
- ✅ Chiffrement AES-128 avec génération automatique de clés par résolution
- ✅ Playlist maître pour lecture adaptative
- ✅ Arborescence claire dans
public/videos/{id}
- ✅ Mises à jour en base avec tous les chemins utiles
- ✅ Gestion des erreurs et statut (processing, completed, failed)
- ✅ Logs détaillés pour débogage et suivi
Caractéristiques « pro » intégrées
- Traitement asynchrone via RabbitMQ et workers Symfony Messenger
- Support pour la rotation des clés (AES générées automatiquement)
- Résilience : relances possibles en cas d’échec
- Nettoyage des fichiers et validations de base
- Encodage optimisé (libx264, preset
fast
, débit contrôlé)
Expérience finale côté admin
En pratique :
- L’admin upload une vidéo via EasyAdmin.
- Le worker l’envoie dans
VideoProcessingService
. - Une fois terminé, la vidéo est disponible immédiatement en streaming HLS adaptatif avec toutes les qualités, chiffrée, et prête à être servie derrière Varnish.
👉 Résultat : “Upload once, stream everywhere”. Une seule action côté utilisateur, et tu obtiens un rendu multi-qualité + HLS adaptatif + chiffrement AES-128 sans intervention manuelle.


UI / UX : plus de contrôle pour l’utilisateur
Comme nous sommes maintenant dans une V2 de notre POC, il est temps d’ajouter un peu de confort côté interface.
- Lecture par défaut : on garde une compatibilité maximale en chargeant la vidéo de façon classique.
- Switch HLS : via un simple select, l’utilisateur peut basculer vers le mode HLS adaptatif.
- Téléchargement : chaque rendu (480p, 720p, 1080p) peut être téléchargé directement, voire le dossier complet si nécessaire.
Simulation de DRM
Dans cette V2, on a aussi ajouté un chiffrement des segments HLS pour simuler un DRM maison.
Concrètement :
- Chaque qualité (480p, 720p, 1080p) a sa propre clé AES-128, stockée en dehors du webroot.
- La playlist
.m3u8
pointe vers une URL protégée qui délivre la clé seulement si les conditions sont réunies (ex. un token valide). - Sans cette clé, la vidéo est inutilisable : même si vous essayez un screen record ou un téléchargement sauvage, le flux reste illisible.
👉 Résultat : si vous tentez de lire la vidéo sans clé, ou de capturer l’écran, vous obtenez simplement un écran noir.
C’est une protection légère (pas un vrai DRM au sens Widevine/FairPlay), mais elle illustre parfaitement le principe : une vidéo chiffrée, une clé d’accès contrôlée, et un player qui gère la décryption en temps réel.

Conclusion
Cette deuxième partie était clairement plus technique que la première, mais c’était un passage obligé avant la V3 “mini Netflix”.
On a maintenant :
- un flux de travail complet (upload → worker → encodage → publication),
- la gestion HLS adaptatif pour une lecture fluide,
- et un petit DRM maison (chiffrement AES-128) suffisant pour un POC.
L’approche reste volontairement incrémentale : tester, valider, améliorer.
👉 Dans la prochaine étape, pour la V3, on rajoutera :
- un système de login pour distinguer public/privé,
- une barre de recherche et une page de streaming dédiée,
- une UI sobre mais efficace,
- et surtout, l’intégration de Varnish pour mettre en cache certaines vidéos, y compris des contenus longs (je testerai avec une vidéo de 20 minutes en local).
Bref, on se rapproche pas à pas d’un vrai mini Netflix en PHP avec Symfony 7, FrankenPHP et FFmpeg.
Cet article t’a plu ?
Cet article est la partie 2 de ma série consacrée à un petit POC de service de streaming maison.
👉 Et comme toujours, si le sujet t’intéresse ou si tu veux partager tes retours d’expérience, on en discute en commentaire !
Laisser un commentaire