Introduction
Ça y est, nous voilà déjà à la partie 4 de notre aventure technique ! Depuis le début, on a mis en place un vrai pipeline de streaming vidéo avec Symfony 7 et FrankenPHP : intégration de FFmpeg pour générer nos vidéos, un worker dédié orchestré par RabbitMQ pour traiter tout ça en asynchrone, et même une diffusion en HLS segmentée toutes les 6 secondes, parfaitement optimisée pour être mise en cache par Varnish.
Mais il manquait encore une brique essentielle pour rendre notre plateforme digne des géants du streaming : le sous-titrage automatique.
👉 Accessibilité, SEO vidéo, confort utilisateur… les sous-titres ne sont plus un bonus, mais une nécessité.
Dans cette partie 4, nous allons donc enrichir notre workflow existant pour y intégrer la génération de sous-titres automatiques (anglais et français) grâce à Symfony AI. L’utilisateur final pourra alors activer ou changer la langue des sous-titres directement depuis le player, exactement comme sur Netflix ou YouTube.
Alors, prêt à ajouter cette nouvelle brique logique dans notre application ?
Symfony AI en développement : une révolution intégrée au framework
Vous me connaissez : je suis de près toutes les évolutions de l’écosystème Symfony. Et récemment, Fabien Potencier et la core team ont dévoilé une nouveauté majeure qui va transformer nos applications en 2025 : Symfony AI.
Concrètement, il s’agit d’une suite de classes, services et intégrations pensée pour exploiter l’IA directement dans vos projets Symfony. Avec Symfony AI, on peut :
- Analyser un flux vidéo ou audio en temps réel (ASR – Automatic Speech Recognition).
- Traduire automatiquement du texte ou de la voix dans plusieurs langues.
- Connecter des LLMs (un ou plusieurs, selon le contexte et la logique métier).
- Analyser des données, créer des chatbots ou enrichir l’expérience utilisateur avec des modèles d’IA.
Bref, de quoi transformer une application web “classique” en une plateforme intelligente.
Et pour notre cas précis, ça tombe à pic : nous allons brancher Symfony AI sur l’API d’OpenAI, l’acteur numéro 1 de l’intelligence artificielle, afin de générer automatiquement des sous-titres (au format .vtt
et .srt
) en anglais et en français.
👉 Résultat : chaque vidéo uploadée pourra être enrichie de sous-titres multilingues, accessibles et optimisés pour l’expérience utilisateur comme pour le SEO.
OpenAI et Whisper : la magie du speech-to-text
Comme évoqué plus haut, nous allons nous appuyer sur OpenAI pour générer automatiquement nos sous-titres. Et le cœur de cette brique, c’est Whisper, un modèle de reconnaissance vocale devenu une référence en matière de speech-to-text.
L’expérience côté utilisateur est bluffante :
Il suffit d’uploader un fichier vidéo, et tout le reste est géré automatiquement par notre workflow Symfony AI + workers.
- On extrait la piste audio de la vidéo.
- On envoie cette piste à l’API Whisper.
- L’IA retourne la transcription texte.
- Selon la langue détectée (français, anglais…), nous générons directement le fichier de sous-titres au format
.vtt
ou.srt
.
Résultat : sans intervention humaine, notre plateforme de streaming produit des sous-titres multilingues exploitables immédiatement dans le player.
Alors certes, Whisper n’est pas un modèle multimodal “super-puissant” comme GPT-5, mais dans son domaine – la transcription audio – il excelle : rapidité, précision, et support de nombreuses langues.
👉 Ce qui me fascine le plus ? C’est qu’avec l’IA et les LLMs, ce qui était autrefois extrêmement complexe (découpage audio, alignement temporel, formatage VTT/SRT) devient aujourd’hui presque trivial à mettre en place.

Architecture de nos services Docker en action
Sur la capture ci-dessus, on voit l’orchestration complète de notre stack de streaming vidéo grâce à Docker Compose :
- app-1 : notre application Symfony 7 propulsée par FrankenPHP.
- subtitle-worker-1 : le worker dédié à la génération de sous-titres automatiques (Symfony AI + Whisper).
- video-worker-1 : le worker responsable du traitement FFmpeg/HLS et du multi-résolution.
- database-1 : une base PostgreSQL 16 Alpine pour stocker les métadonnées (vidéos, sous-titres, jobs).
- rabbitmq-1 : le broker RabbitMQ (avec interface de management) qui gère la file de messages asynchrones.
- varnish-1 : le proxy Varnish Cache qui sert nos chunks HLS avec une latence minimale.
- mailer-1 : un conteneur Mailpit pour la gestion et le test des emails (notifications upload/processing).
👉 Cette organisation en micro-services découplés rend notre plateforme scalable : chaque worker peut monter en charge indépendamment, et Varnish permet de soulager l’infrastructure grâce à la mise en cache des vidéos et sous-titres.
Symfony et l’art du service : injection de dépendances avant tout
Qui dit ajout de fonctionnalité dans une application Symfony, dit forcément création de service. C’est un principe fondateur du framework : on mise sur l’injection de dépendances plutôt que sur du code “fourre-tout” placé dans un contrôleur.
Même dans le cadre d’un POC, je m’efforce de respecter les principes SOLID.
👉 Alors oui, on pourrait très bien coller la logique de génération des sous-titres dans un contrôleur, et ça fonctionnerait. Mais ce serait clairement un anti-pattern.
C’est pourquoi j’ai ajouté un service dédié exclusivement à la traduction (français) et à la génération de sous-titres.
- Plus lisible.
- Plus testable.
- Plus extensible (on pourra un jour ajouter d’autres langues, ou d’autres providers IA).
Bien entendu, dans un contexte pro en production, on irait beaucoup plus loin : plusieurs services spécialisés, un découplage plus poussé, et une meilleure gestion des erreurs et retries.
Pour ce prototype, je me suis appuyé sur le Symfony AI Bundle. Et vous allez le voir : malgré ce que l’on pourrait croire, la mise en place n’a rien de complexe.
Le seul bémol, c’est que le bundle est moins versatile qu’une librairie comme LLPhant (plus souple mais aussi plus stable). Symfony AI fait le job, et il le fait de manière robuste et prévisible.
Service de génération de sous-titres (Symfony AI + Whisper)
Ce service orchestré côté back gère tout le pipeline : extraction audio via FFmpeg, transcription Whisper (Symfony AI), génération des fichiers .vtt, éventuelle segmentation VTT pour HLS et mise à jour de l’entité Videos
. Idéal pour un worker Messenger.
<?php
namespace App\Service;
use App\Entity\Videos;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
use Symfony\AI\Platform\Bridge\OpenAi\Whisper;
use Symfony\AI\Platform\Message\Content\Audio;
use Symfony\Component\Filesystem\Filesystem;
class SubtitleGenerationService
{
public function __construct(
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
private Filesystem $filesystem,
private string $projectDir,
private string $openaiApiKey,
private VTTSegmentationService $segmentationService
) {
}
public function generateSubtitles(Videos $video, array $targetLanguages = ['en', 'fr']): void
{
try {
$this->logger->info('Starting subtitle generation', [
'video_id' => $video->getId(),
'languages' => $targetLanguages
]);
// Get the original video file path
$originalPath = $video->getOriginalPath();
if (!$originalPath || !file_exists($originalPath)) {
throw new \RuntimeException('Original video file not found');
}
// Extract audio from video for Whisper processing
$audioPath = $this->extractAudio($originalPath, $video->getId());
// Generate transcription with Whisper (auto-detect language)
$transcriptionData = $this->transcribeAudio($audioPath, $video);
// Process transcription and create subtitle files
$subtitlePaths = [];
foreach ($targetLanguages as $language) {
$subtitlePath = $this->createSubtitleFile(
$transcriptionData,
$video->getId(),
$language
);
if ($subtitlePath) {
$subtitlePaths[$language] = $subtitlePath;
}
}
// Update video entity with subtitle information
$this->updateVideoWithSubtitles($video, $subtitlePaths);
// Segment VTT files for HLS if video has HLS segments
$hlsSubtitleResults = [];
foreach ($targetLanguages as $language) {
if (isset($subtitlePaths[$language])) {
try {
$this->logger->info('Starting VTT segmentation for HLS', [
'video_id' => $video->getId(),
'language' => $language
]);
$segmentResult = $this->segmentationService->segmentVTTForHLS(
$video->getId(),
$subtitlePaths[$language],
$language
);
$hlsSubtitleResults[$language] = $segmentResult;
} catch (\Exception $e) {
$this->logger->warning('VTT segmentation failed for language', [
'video_id' => $video->getId(),
'language' => $language,
'error' => $e->getMessage()
]);
}
}
}
// Update master playlist with subtitles if any segmentation was successful
if (!empty($hlsSubtitleResults)) {
try {
$this->segmentationService->updateMasterPlaylistWithSubtitles(
$video->getId(),
array_keys($hlsSubtitleResults)
);
$this->logger->info('Master playlist updated with subtitles', [
'video_id' => $video->getId(),
'languages' => array_keys($hlsSubtitleResults)
]);
} catch (\Exception $e) {
$this->logger->warning('Failed to update master playlist', [
'video_id' => $video->getId(),
'error' => $e->getMessage()
]);
}
}
// Clean up temporary audio file
if (file_exists($audioPath)) {
unlink($audioPath);
}
// Mark subtitles as successfully generated
$video->setSubtitlesGenerated(true);
$this->logger->info('Subtitle generation completed', [
'video_id' => $video->getId(),
'subtitle_files' => $subtitlePaths,
'hls_segments' => array_keys($hlsSubtitleResults),
'subtitles_generated' => true
]);
} catch (\Exception $e) {
// Mark subtitles as failed to generate
$video->setSubtitlesGenerated(false);
$video->setUpdatedAt(new \DateTimeImmutable());
$this->logger->error('Subtitle generation failed', [
'video_id' => $video->getId(),
'error' => $e->getMessage(),
'subtitles_generated' => false
]);
throw $e;
}
}
private function extractAudio(string $videoPath, int $videoId): string
{
$audioPath = $this->getSubtitleStoragePath($videoId) . '/audio.wav';
$this->filesystem->mkdir(dirname($audioPath));
// Extract audio using FFmpeg
$command = [
'ffmpeg',
'-i', $videoPath,
'-vn', // No video
'-acodec', 'pcm_s16le', // PCM 16-bit
'-ar', '16000', // 16kHz sample rate (optimal for Whisper)
'-ac', '1', // Mono
'-y', // Overwrite output
$audioPath
];
$process = new \Symfony\Component\Process\Process($command);
$process->setTimeout(1800); // 30 minutes timeout
$process->run();
if (!$process->isSuccessful()) {
throw new \RuntimeException('Audio extraction failed: ' . $process->getErrorOutput());
}
return $audioPath;
}
private function transcribeAudio(string $audioPath, Videos $video): array
{
// Check if file exists
if (!file_exists($audioPath)) {
throw new \RuntimeException('Audio file not found: ' . $audioPath);
}
$this->logger->info('Starting Whisper transcription', [
'audio_path' => $audioPath,
'file_size' => filesize($audioPath)
]);
// Use the latest approach from official example
$platform = PlatformFactory::create($this->openaiApiKey);
$model = new Whisper(Whisper::WHISPER_1);
// Create Audio object from file path
$file = Audio::fromFile($audioPath);
// Pass options as third parameter
$options = [
'response_format' => 'verbose_json',
'language' => null, // Auto-detect
];
$result = $platform->invoke($model, $file, $options);
// Log the raw response for debugging
$this->logger->info('Whisper API response received', [
'content_type' => get_class($result)
]);
// Get response content using asText() like in official demo
$responseContent = $result->asText();
// If we get plain text instead of JSON, create basic segments
$data = json_decode($responseContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
// If it's not JSON, treat it as plain text transcription
$this->logger->info('Received plain text response, creating basic segments', [
'text_preview' => substr($responseContent, 0, 200)
]);
// Create a single segment with the full text
$data = [
'language' => 'fr', // Auto-detected or default
'segments' => [
[
'start' => 0.0,
'end' => (float) $video->getDuration(),
'text' => trim($responseContent)
]
]
];
}
if (!$data || !isset($data['segments'])) {
$this->logger->error('Invalid Whisper response format', [
'data_type' => gettype($data),
'data_keys' => is_array($data) ? array_keys($data) : 'not_array',
'response_preview' => substr($responseContent, 0, 500)
]);
throw new \RuntimeException('Invalid Whisper response format');
}
$this->logger->info('Whisper transcription successful', [
'segments_count' => count($data['segments']),
'detected_language' => $data['language'] ?? 'unknown'
]);
return $data;
}
private function createSubtitleFile(array $transcriptionData, int $videoId, string $language): ?string
{
$subtitleDir = $this->getSubtitleStoragePath($videoId);
$this->filesystem->mkdir($subtitleDir);
$subtitlePath = $subtitleDir . "/subtitles_{$language}.vtt";
// Generate VTT content
$vttContent = $this->generateVTTContent($transcriptionData, $language);
file_put_contents($subtitlePath, $vttContent);
return $subtitlePath;
}
private function generateVTTContent(array $transcriptionData, string $targetLanguage): string
{
$vtt = "WEBVTT\n\n";
$sourceLanguage = $transcriptionData['language'] ?? 'en';
foreach ($transcriptionData['segments'] as $index => $segment) {
$start = $this->formatTimestamp($segment['start']);
$end = $this->formatTimestamp($segment['end']);
$text = $segment['text'];
// If target language differs from source, translate the text
if ($targetLanguage !== $sourceLanguage) {
$text = $this->translateText($text, $sourceLanguage, $targetLanguage);
}
$vtt .= sprintf("%d\n%s --> %s\n%s\n\n",
$index + 1,
$start,
$end,
trim($text)
);
}
return $vtt;
}
private function translateText(string $text, string $sourceLanguage, string $targetLanguage): string
{
// For now, skip translation and return original text
// TODO: Implement proper translation using another AI model
$this->logger->info('Translation skipped for POC', [
'source' => $sourceLanguage,
'target' => $targetLanguage
]);
return $text;
}
private function formatTimestamp(float $seconds): string
{
$hours = floor($seconds / 3600);
$minutes = floor(($seconds % 3600) / 60);
$secs = $seconds % 60;
return sprintf('%02d:%02d:%06.3f', $hours, $minutes, $secs);
}
private function getSubtitleStoragePath(int $videoId): string
{
return $this->projectDir . '/public/videos/' . $videoId . '/subtitles';
}
private function updateVideoWithSubtitles(Videos $video, array $subtitlePaths): void
{
// Add subtitle paths to video entity
$resolutions = $video->getResolutions() ?? [];
foreach ($subtitlePaths as $language => $path) {
$publicPath = '/videos/' . $video->getId() . '/subtitles/subtitles_' . $language . '.vtt';
$resolutions['subtitle_' . $language] = $publicPath;
}
$video->setResolutions($resolutions);
$video->setUpdatedAt(new \DateTimeImmutable());
$this->entityManager->flush();
}
}
Ce que fait ce service (en bref)
- FFmpeg ➜ WAV mono 16 kHz (optimal pour Whisper).
- Whisper (Symfony AI) ➜ transcription + détection de langue.
- Génération VTT (format WEBVTT, timecodes
HH:MM:SS.mmm
). - Option HLS ➜ découpe VTT par segments et maj de la master playlist.
- Persistance ➜ chemins publiquement servis sous
/public/videos/{id}/subtitles
.
Démonstration vidéo : sous-titres automatiques en action
Comme le montre la vidéo ci-dessous, la génération de sous-titres fonctionne parfaitement.
Le process est totalement automatisé : il suffit d’uploader une vidéo via EasyAdmin, et Symfony AI s’occupe du reste (extraction audio, transcription Whisper, génération des fichiers .vtt
et intégration dans la playlist HLS).
Ici, la démonstration est faite sur Safari avec le player vidéo natif : le navigateur sélectionne par défaut le sous-titrage automatique.
👉 L’expérience utilisateur est déjà convaincante, même si l’on note quelques petites imprécisions de synchronisation image/son. Rien d’étonnant : il s’agit bien d’un POC technique, pas encore d’une application de production.
Bien entendu, l’utilisateur peut à tout moment activer ou désactiver les sous-titres, ou choisir une langue si plusieurs pistes sont disponibles.
Disclaimer : pour illustrer ce test, j’ai utilisé un trailer issu de YouTube. C’est uniquement à des fins de démonstration en local, et il ne faut en aucun cas reproduire ce procédé dans un contexte public ou en production.
Segmentation des sous-titres pour HLS

Comme pour la vidéo, nos sous-titres ne sont pas servis sous la forme d’un seul fichier .vtt
, mais bien segmentés pour coller aux chunks HLS de 6 secondes.
Dans l’exemple ci-dessus, on retrouve :
- Un fichier
playlist.m3u8
qui sert de manifest HLS pour les sous-titres. - Une série de fichiers
segment_000.vtt
,segment_001.vtt
,segment_002.vtt
…, chacun correspondant aux sous-titres d’un chunk vidéo donné.
👉 Ce fonctionnement apporte deux gros avantages :
- Synchronisation parfaite : chaque segment
.vtt
est aligné avec son segment vidéo.ts
ou.m4s
. - Mise en cache optimisée : Varnish ou un CDN peut stocker chaque chunk individuellement, ce qui réduit la latence et améliore les performances globales.
En pratique, le player HLS lit la playlist m3u8
et charge dynamiquement les segments de sous-titres au fil de la lecture, exactement comme pour la vidéo.
Conclusion
Au fil de ces 4 étapes, nous avons réussi à bootstrapper un vrai projet de gestion de vidéos :
- mise en place de l’upload,
- création d’un worker avec queue dédiée,
- génération de chunks HLS pour le streaming,
- et même sous-titrage automatique grâce à Symfony AI et Whisper.
Bien sûr, on reste encore loin d’une application production ready : il manque de la robustesse, de la sécurité, des métriques et un vrai monitoring. Mais c’est justement l’intérêt d’un POC : tester, se planter parfois, apprendre et améliorer en continu.
Le parcours n’est pas terminé. Les prochaines étapes seront probablement :
- travailler l’UI pour une meilleure expérience utilisateur,
- lancer des tests de charge pour éprouver le pipeline,
- et affiner le concept pour aller plus loin.
Car être développeur, ce n’est pas seulement coder des kilo-tonnes de lignes. C’est aussi savoir rollback, se poser les bonnes questions, choisir la bonne approche, et surtout… se lancer.
👉 Et toi, tu as déjà tenté un projet de streaming avec Symfony + FrankenPHP ?
Partage ton retour en commentaire, ça m’intéresse !
Laisser un commentaire