Skip to main content

Vidéo — détails techniques

Fiche fonctionnelle

Description et fonctionnalités de ce module : Vidéo.

Module fr.tech.openent~video (dépôt open-ent/video), backend Vert.x + frontend React sur le socle Explorer (@open-ent/explorer, @open-ent/client), comme Carte mentale ou Communautés. L'encodage vidéo est délégué à un service dédié dockerisé (media-server/, Bun + ffmpeg), pas exécuté dans la JVM du module.

Architecture

Fiche vidéo (CRUD explorateur)

  • Création : POST /video (VideoController#create) — comme les autres applications explorateur, la modale générique ne crée qu'une fiche titre + description, sans fichier. DefaultVideoService#create délègue l'indexation à VideoExplorerPlugin (extends ExplorerPluginResourceMongo, collection Mongo videos).
  • Attacher un fichier : l'écran de fiche (features/Video/Video.tsx) affiche un formulaire d'envoi tant que workspaceDocumentId est vide. POST /video/encode?videoid=<id> — même route que l'éditeur riche (ci-dessous), avec en plus le paramètre videoid pointant la fiche à compléter. Côté backend, VideoController#runEncodingPipeline bascule sur videoService.update(...) (au lieu de create(...)) quand videoid est présent, pour éviter de créer un doublon.
  • DefaultVideoService#update relit le document Mongo complet après écriture avant de le réindexer (plugin.notifyUpsert) : passer directement le payload partiel de la requête écraserait les champs non modifiés (ex. name) dans l'index de recherche Explorer.
  • Renommage : PUT /video/:videoId avec le seul champ name. Deux points d'entrée « sur place » (features/VideoTitle/VideoTitle.tsx, monté par la fiche et par le studio) s'appuient sur cette route générique, exactement comme la modale de l'Explorer qui passe, elle, par VideoResourceService#update. L'update Mongo étant un $set champ par champ, n'envoyer que name laisse intacts la miniature, le montage et les métadonnées d'encodage. Le droit exigé est video.manager (ActionType.RESOURCE) : le frontend n'affiche le crayon que si le loader de routes/video-root a posé manager: true dans le store store/rights. La décision appliquée au brouillon de titre (vide → refus, inchangé après rognage → aucune écriture) est isolée dans features/VideoTitle/rename.ts et couverte par un test unitaire Vitest (rename.test.ts).
  • Dossiers, déplacement, corbeille, suppression : entièrement pris en charge par le module explorer générique (FoldersControllerProxyFoldersControllerExplorer), aucune route spécifique côté vidéo.

Contrat d'encodage (partagé avec l'éditeur riche)

  • POST /video/encode?captation=&duration=&videoid= (multipart) — contrat inchangé consommé aussi par MediaLibrary.tsx (bibliothèque openent-frontend-framework, picker vidéo de l'éditeur de texte enrichi) : videoid est omis dans ce cas, une nouvelle fiche est créée après encodage.
  • GET /video/status/:id — statut du job (running / succeed / error), suivi en mémoire par VideoJobStore (non partagé entre instances — limite connue en cas de déploiement multi-instance).
  • VideoEncoderClient (mirroir de org.entcore.common.pdf.NodePdfClient) relaie le fichier brut au media-server (video-encoder.url dans la config du module) et récupère le mp4 encodé (h264/aac/faststart) ; le fichier encodé est ensuite stocké via Storage#writeBuffer dans la même collection Mongo (documents) que l'espace documentaire, pour rester lisible par GET /workspace/document/:id.
  • Le droit fr.tech.openent.video.controllers.VideoController|capture (méthode nommée capture, pas un nom métier) est en dur dans MediaLibrary.tsx — package et nom de méthode ne peuvent pas être renommés sans casser l'intégration éditeur riche.

Capture navigateur (webcam/écran)

  • features/VideoCapture/useMediaCapture.ts : trois modes (camera / screen / screen-camera), via getUserMedia/getDisplayMedia + MediaRecorder. En mode composite, deux <video> hors écran alimentent un <canvas> composé (composite.ts) qui incruste la caméra dans un coin de l'écran ; canvas.captureStream() fournit la piste vidéo enregistrée. Plusieurs pistes audio (micro + audio système) sont mixées via AudioContext quand les deux sont présentes.
  • Le blob enregistré (webm, getBestSupportedMimeType()) est envoyé au même contrat que l'upload — POST /video/encode?captation=true&duration=&videoid= — avec captation=true : VideoController#hasAllowedExtension est sauté (le conteneur webm de MediaRecorder ne fait pas partie de accept-videoupload-extensions), et c'est max-videoduration-minutes qui borne la taille à la place (arrêt automatique de l'enregistrement à la durée max).
  • Tests e2e : la webcam/le partage d'écran n'existant pas sur le runner headless, 48_video/shared.ts#installFakeMediaCapture remplace getUserMedia/getDisplayMedia par un flux synthétique (canvas.captureStream() + oscillateur AudioContext) via page.addInitScript, posé avant toute navigation — MediaRecorder, l'encodage et l'upload restent le vrai pipeline.

Media-server (media-server/)

Conteneur autonome (Bun + ffmpeg), sur le modèle du pdf (node-pdf-generator) : pas de route Traefik (appel serveur-à-serveur interne au réseau docker), image publiée séparément (ghcr.io/open-ent/video-media-server). POST /encode (multipart) → mp4 ; GET /health.

Studio d'édition (trim, zoom, arrière-plan, sous-titres, export)

Conçu en s'inspirant du modèle de données de CapSoftware/Cap (crates/project/src/configuration.rs : BackgroundSource, ZoomSegment, CaptionSegment) — le moteur de rendu de Cap est en Rust + wgpu (GPU natif), inutilisable tel quel sur cette stack Bun/ffmpeg ; seul le modèle de données a été repris, le rendu est entièrement réimplémenté en filtres ffmpeg.

  • Montage (EDL) : stocké dans le champ edit de la fiche Mongo (trim, zoom, background, captions), écrit via le PUT /video/:videoId générique existant (pas de route dédiée à la sauvegarde du brouillon — features/VideoEditor/VideoEditor.tsx l'autosave, débouncé 1,2 s). Modèle miroir côté frontend (models/edit.ts) et media-server (lib/media-edit.ts, EditProject) — zoom et sous-titres sont horodatés en temps SOURCE (avant découpe), projetés vers le temps de sortie à travers les plages de trim conservées au moment du rendu (mapSourceSegmentsToOutput) et de la génération du VTT (CaptionsVttBuilder, même logique dupliquée côté Java pour la piste non gravée) — évite de tout retimer à chaque ajustement du trim.
  • Rendu ffmpeg (media-server/src/lib/media-edit.ts, route POST /video/render, même contrat de job asynchrone que /video/process — job créé dans job-manager.ts, statut/résultat/ annulation réutilisés tels quels) : découpe le clip à chaque frontière de segment de zoom (buildRenderSegments), applique un filtre zoompan avec une expression if() en escalier (montée/palier/descente) sur les segments zoomés, concatène (mêmes fonctions batch que l'existant renderEditedVideo) ; passe éventuelle de composition du fond (pad/gblur+overlay/image importée, dégradé généré via le filtre geq) ; passe éventuelle de gravure des sous-titres via un fichier .ass stylé (subtitles=).
  • Backend : VideoController#renderEdit (POST /:videoId/edit/render) lit la fiche, résout le fichier source, démarre le job via VideoEncoderClient#startRenderJob (même polling que l'encodage initial), puis remplace workspaceDocumentId/thumbnail/sizeBytes de la fiche à la complétion — réutilise le mécanisme de remplacement déjà validé pour videoid sur /encode, pas de logique parallèle. POST /:videoId/edit/background-image héberge une image de fond importée dans le workspace. GET /:videoId/captions.vtt sert la piste de sous-titres non destructive.
Piège : workspaceDocumentId ≠ id de stockage

Le champ workspaceDocumentId d'une fiche est l'_id Mongo du document workspace (collection documents), pas l'id que Storage#readFile attend — celui-ci vit dans le champ file du même document (voir org.entcore.workspace.controllers.WorkspaceController#getPreview, qui fait la même résolution). Appeler storage.readFile(workspaceDocumentId, …) directement ne lève aucune erreur (Storage#readFile n'a pas de canal d'échec, il rappelle simplement le handler avec un Buffer null) — le bug est passé inaperçu jusqu'à un NullPointerException non rattrapé plus loin dans le pipeline (job de rendu bloqué indéfiniment en running, aucun appel à jobStore.fail(...)). VideoController#resolveStorageFileId fait la résolution documents._iddocuments.file avant tout storage.readFile, avec échec propre du job si le document ou le fichier est introuvable.

Paramètres (bloc du module dans ent-core.yaml)

ParamètreRôle
video-encoder.urlURL du media-server (VIDEO_ENCODER_URL)
publicConf.max-videosize-mbytesTaille max acceptée à l'envoi (défaut 50 Mo)
publicConf.max-videoduration-minutesDurée max pour une captation webcam (défaut 3 min)
publicConf.accept-videoupload-extensionsExtensions autorisées pour un envoi de fichier (défaut mp4, mov, avi)

Le contrôle porte uniquement sur l'extension du fichier (VideoController#hasAllowedExtension), pas sur le Content-Type HTTP envoyé par le navigateur. Quel que soit le format d'origine accepté, le media-server ré-encode systématiquement en MP4 (H.264/AAC, +faststart) avant stockage : un MOV n'est donc jamais lu tel quel par le <video> du frontend.

Limites connues

  • Formulaire d'envoi non stylé (features/Video/Video.tsx en l'absence de fichier) : input natif + bouton brut, pas encore repris dans la charte graphique du module.
  • Espace de noms explorer sans traduction : les libellés génériques de la bibliothèque @open-ent/explorer (ex. l'icône vide d'illustration) retombent sur leur valeur par défaut faute de ressource i18n dédiée — gap partagé par toutes les applications explorateur, pas spécifique à Vidéo.
  • VideoJobStore en mémoire : le suivi des jobs d'encodage ne survit pas à un redémarrage et n'est pas partagé entre instances.
  • Aucun déploiement production pour le module ni le media-server : pas de chart Helm référençant video (constaté à date, à faire sur le modèle du service pdf/content-transformer).
  • Régénération de la miniature après export parfois en échec (POST /video/thumbnail du media-server retourne 500 sur certains clips très courts, constaté le 2026-09-01) : dégradation gracieuse — le job de rendu réussit quand même, la fiche garde simplement sa miniature précédente plutôt que d'être mise à jour. Cause racine non creusée (probablement une combinaison timestamp/durée limite pour un clip d'~1 s).
  • Sous-titres auto-générés (transcription) : hors périmètre du studio d'édition actuel, ajout manuel uniquement (voir plus haut) — pas de moteur de reconnaissance vocale embarqué.

Couverture de tests

10 test(s) e2e couvrent ce module, dans 9 scénario(s) — dossier apps/open-ent-e2e/src/modules/48_video. S'y ajoute un test unitaire Vitest côté module (frontend/src/features/VideoTitle/rename.test.ts, pnpm test).

10 test(s) e2e dans 9 scénario(s) — voir le détail

Vidéo 01_acces_au_module.spec.ts

  • accès au module

Vidéo 02_ajout_video.spec.ts

  • création d'une fiche puis envoi du fichier (jusqu'à la lecture)

Vidéo 03_organisation_dossiers.spec.ts

  • création d'un dossier et déplacement d'une vidéo

Vidéo 04_suppression.spec.ts

  • mise à la corbeille puis suppression définitive

Vidéo — capture 05_capture_webcam.spec.ts

  • enregistrer une vidéo depuis la caméra (choix de source, enregistrement, relecture, envoi jusqu'à la lecture)

Vidéo — capture 06_capture_ecran.spec.ts

  • enregistrer une vidéo depuis le partage d'écran

Vidéo — capture 07_capture_ecran_camera.spec.ts

  • enregistrer une vidéo depuis l'écran et la caméra (bulle caméra incrustée)

Vidéo — studio d'édition 08_studio_edition.spec.ts

  • découpe, zoom, arrière-plan, sous-titre puis export (jusqu'au remplacement du fichier sur la fiche)

Vidéo — renommage du titre 09_renommage_titre.spec.ts

  • renomme depuis la fiche puis depuis le studio (refus du titre vide, validation au clavier, annulation sans écriture, propagation à la bibliothèque)

Les captures de la fiche fonctionnelle proviennent de ces spécifications, rejouées avec le profil enseignant et un fichier vidéo de test généré par ffmpeg (1 s, 160×90) — ou, pour la capture navigateur, un flux synthétique (voir « Capture navigateur » ci-dessus).

Le défaut lilit.upreti001 du profil enseignant ne se connecte pas sur l'environnement local : y rejouer les specs demande E2E_ENSEIGNANT_USER=amelie.martin E2E_ENSEIGNANT_PASS=amelie.martin (persona de démo, seul compte enseignant dont le groupe porte les rôles de l'application Vidéo en local). Les captures du renommage ont été produites ainsi.

Conformité

Évaluation au référentiel Open ENT NG (module video).

Maillon de la chaîne qualitéRéférence
🎯 Fonctionnalités attenduesfiche fonctionnelle
🧪 Tests réalisésdossier e2e 48_video