diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa7daf..18caa26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Non publié ### ▶️ Lecture vidéo +- **Fix du démarrage des enregistrements** : la lecture partait au milieu du programme et se coupait aussitôt, obligeant à relancer une deuxième fois. Une playlist encore en cours de transcodage n'a pas d'`EXT-X-ENDLIST` : hls.js la traite comme du direct et démarrait donc au « bord du direct », collé au front d'encodage, sans aucune avance de segments. `startPosition: 0` hors direct, plus la neutralisation du rattrapage de latence (qui accélérait la lecture puis forçait un saut en avant pour rejoindre un direct inexistant) +- **Plus de ré-encodage inutile à la lecture d'un enregistrement** : la capture étant faite en copie, le fichier contient déjà du H.264 dans la quasi-totalité des cas. Il est désormais servi tel quel (`-c:v copy`, seul l'audio est converti en AAC) au lieu d'être ré-encodé à peine plus vite que le temps réel — la segmentation va maintenant à la vitesse du disque, l'enregistrement devient navigable en quelques secondes. Les codecs illisibles par le navigateur (HEVC, MPEG-2…) restent ré-encodés +- **Démarrage plus robuste** : le serveur attend trois segments d'avance avant de servir la playlist (au lieu d'un seul, qui laissait le lecteur courir après l'encodeur), et sert ce qu'il a plutôt qu'une erreur si le délai expire. Côté navigateur, le délai d'attente de playlist passe de 10 s à 45 s hors direct — une session FFmpeg qui démarre n'est plus prise pour un manifeste mort — et le lecteur de bureau réessaie seul jusqu'à 3 fois, comme le faisait déjà le lecteur mobile - **Lecteur avancé pour les enregistrements** : barre de progression réellement utilisable (avance, retour, saut n'importe où), pause/reprise, et reprise automatique là où on s'était arrêté. La playlist d'un enregistrement était servie en `EXT-X-PLAYLIST-TYPE:VOD` : hls.js la considérait comme définitive et ne voyait donc que les quelques secondes déjà transcodées à l'ouverture. Elle passe en `EVENT` (la zone navigable grandit avec l'encodage) et un saut hors de cette zone relance FFmpeg à la position visée (`?start=`), au lieu d'attendre que l'encodeur y arrive - **Reprise des enregistrements** : la liste affiche « Reprendre à … » avec la progression, et propose au clic de reprendre ou de repartir du début ; la durée affichée est mesurée par ffprobe (`duration_seconds`) et non plus déduite des horaires programmés — un enregistrement arrêté en avance donnait une barre fausse - **Contrôles enrichis (VOD, séries, enregistrements)** : sauts ±10 s et ±1 min, sélecteur de vitesse (0,5× à 2×), portion déjà transcodée visible sur la barre, et raccourcis clavier `K`/espace, `J`/`L`, Maj+←/→, `0`-`9`, `F`, `M`, `,`/`.` diff --git a/bin/api/recordings_api.dart b/bin/api/recordings_api.dart index f2a403c..cd0475b 100644 --- a/bin/api/recordings_api.dart +++ b/bin/api/recordings_api.dart @@ -6,6 +6,7 @@ import '../database/database.dart'; import '../models/recording.dart'; import '../models/user.dart'; import '../services/recording_scheduler.dart'; +import '../utils/media_probe.dart'; import '../utils/safe_path.dart'; class RecordingsApi { @@ -97,69 +98,22 @@ class RecordingsApi { ); } - /// Binaire ffprobe : même logique de résolution que FFmpeg côté scheduler - /// (l'image Docker les installe tous deux dans `/usr/local/bin`). - static String _ffprobePath() { - final fromEnv = Platform.environment['FFPROBE_PATH']; - if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv; - if (Platform.isLinux && File('/usr/local/bin/ffprobe').existsSync()) { - return '/usr/local/bin/ffprobe'; - } - return 'ffprobe'; - } - - /// Durées mesurées par ffprobe, mémorisées tant que le fichier ne bouge pas. - /// Sans ce cache, chaque cycle de rafraîchissement (5 s pendant une capture) - /// relancerait un ffprobe par enregistrement. - static final Map - _durationCache = {}; - - /// Durée du média en secondes, ou `null` si elle n'est pas mesurable - /// (ffprobe absent, fichier en cours d'écriture, conteneur sans durée). + /// Durée du média en secondes, ou `null` si elle n'est pas mesurable. + /// + /// Un enregistrement en cours grossit en permanence : le cache de + /// [MediaProbe] serait invalidé à chaque appel et ffprobe tournerait en + /// boucle. La durée programmée suffit tant que la capture n'est pas + /// terminée. Future _probeDuration(Recording r) async { final path = r.filePath; - // Un enregistrement en cours grossit en permanence : le cache serait - // invalidé à chaque appel et ffprobe tournerait en boucle. La durée - // programmée suffit tant que la capture n'est pas terminée. if (path == null || r.status != 'completed') return null; + // Anti path-traversal : la mesure doit rester dans le dossier des + // enregistrements, comme la lecture des logs. final safePath = SafePath.resolveWithin(recordingsDirPath, path); if (safePath == null) return null; - final file = File(safePath); - FileStat stat; - try { - stat = file.statSync(); - if (stat.type == FileSystemEntityType.notFound) return null; - } catch (_) { - return null; - } - - final cached = _durationCache[safePath]; - if (cached != null && - cached.size == stat.size && - cached.mtimeMs == stat.modified.millisecondsSinceEpoch) { - return cached.duration; - } - - try { - final result = await Process.run(_ffprobePath(), [ - '-v', 'error', - '-show_entries', 'format=duration', - '-of', 'default=noprint_wrappers=1:nokey=1', - safePath, - ]); - final value = double.tryParse((result.stdout as String).trim()); - if (value == null || !value.isFinite || value <= 0) return null; - _durationCache[safePath] = ( - size: stat.size, - mtimeMs: stat.modified.millisecondsSinceEpoch, - duration: value, - ); - return value; - } catch (_) { - return null; - } + return MediaProbe.duration(safePath); } Map _enrich(Recording r, DateTime now) { diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 38335a3..dbe9e8b 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -7,6 +7,7 @@ import '../database/database.dart'; import '../models/playlist_config.dart'; import '../services/ffmpeg_session_manager.dart'; import '../utils/log_redactor.dart'; +import '../utils/media_probe.dart'; import 'recording_playlist.dart'; /// Directory for temporary HLS segments @@ -665,12 +666,31 @@ Handler createRecordingStreamHandler( body: 'Physical recording file not found'); } - final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); final start = parseRecordingStart(request.url.queryParameters['start']); final offsetKey = recordingOffsetKey(start); final sessionPrefix = 'rec_${streamId}_t'; final sessionId = 'rec_${streamId}_$offsetKey'; + // La capture est faite en `-c copy` : le fichier contient le codec + // d'origine de la chaîne, presque toujours du H.264 — directement + // lisible en HLS. Le ré-encoder tenait à peine le temps réel en 1080p, + // si bien que la lecture démarrait collée au front d'encodage et se + // coupait à la moindre hésitation. En copie, la segmentation va à la + // vitesse du disque : l'enregistrement devient navigable en quelques + // secondes. Seuls les codecs que le navigateur ne sait pas lire + // (HEVC, MPEG-2…) justifient encore un ré-encodage. + final videoCodec = await MediaProbe.videoCodec(targetUrl); + final canCopyVideo = videoCodec == 'h264'; + final useNvidiaGpu = + !canCopyVideo && (isGpuEnabled?.call() ?? _isNvidiaGpuEnabled()); + + if (!sessionManager.contains(sessionId)) { + print( + '[Recording] $sessionId : ${canCopyVideo ? 'copie vidéo' : 'ré-encodage'}' + ' (codec source : ${videoCodec ?? 'inconnu'})', + ); + } + final session = await sessionManager.getOrStart( id: sessionId, isLive: false, @@ -680,9 +700,12 @@ Handler createRecordingStreamHandler( if (useNvidiaGpu) ...['-hwaccel', 'cuda'], // -ss AVANT -i : recherche rapide par index, sans décoder ce qui // précède. La sortie repart de 0, le lecteur ré-ajoute l'offset. + // En copie, la position atteinte est l'image-clé qui précède. if (start > 0) ...['-ss', '$start'], '-i', targetUrl, - if (useNvidiaGpu) ...[ + if (canCopyVideo) ...[ + '-c:v', 'copy', + ] else if (useNvidiaGpu) ...[ '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', '-rc', 'cbr', '-b:v', '3000k', '-maxrate', '3500k', '-bufsize', '6000k', @@ -710,7 +733,10 @@ Handler createRecordingStreamHandler( ], ); - final result = await sessionManager.waitForPlaylist(session); + // Trois segments d'avance (~12 s) avant de rendre la main : c'est la + // marge qui manquait au démarrage. En copie vidéo elle est produite en + // une fraction de seconde, elle ne coûte donc que sur un ré-encodage. + final result = await sessionManager.waitForPlaylist(session, minSegments: 3); if (!result.ready) { sessionManager.killSession(sessionId); return Response(502, diff --git a/bin/services/ffmpeg_session_manager.dart b/bin/services/ffmpeg_session_manager.dart index 8f477b9..53943ef 100644 --- a/bin/services/ffmpeg_session_manager.dart +++ b/bin/services/ffmpeg_session_manager.dart @@ -143,22 +143,40 @@ class FfmpegSessionManager { } } - /// Waits until the session's playlist references at least one segment. - /// Fails fast when the process dies before producing output, returning - /// the recent stderr for diagnostics. + /// Waits until the session's playlist references at least [minSegments] + /// segments. Fails fast when the process dies before producing output, + /// returning the recent stderr for diagnostics. + /// + /// [minSegments] > 1 donne au lecteur une avance de démarrage : servir la + /// playlist dès le premier segment le fait partir avec 4 s de marge sur un + /// encodeur qui n'a pas fini — la moindre hésitation coupe la lecture. La + /// playlist déjà close (`#EXT-X-ENDLIST`) est renvoyée telle quelle, quel + /// que soit son nombre de segments : rien de plus n'arrivera. Future<({bool ready, String? error})> waitForPlaylist( FfmpegSession session, { Duration timeout = const Duration(seconds: 30), + int minSegments = 1, }) async { final playlistFile = File('${session.dir.path}/playlist.m3u8'); final deadline = DateTime.now().add(timeout); while (DateTime.now().isBefore(deadline)) { - if (playlistFile.existsSync() && - playlistFile.readAsStringSync().contains('.ts')) { - return (ready: true, error: null); + if (playlistFile.existsSync()) { + final content = playlistFile.readAsStringSync(); + final segments = '.ts'.allMatches(content).length; + if (segments >= minSegments || + (segments > 0 && content.contains('#EXT-X-ENDLIST'))) { + return (ready: true, error: null); + } } if (session.exited) { + // Un encodeur qui s'est terminé proprement en ayant produit moins de + // segments que demandé a simplement fini : c'est un succès. + if (session.exitCode == 0 && + playlistFile.existsSync() && + playlistFile.readAsStringSync().contains('.ts')) { + return (ready: true, error: null); + } return ( ready: false, error: 'FFmpeg exited (${session.exitCode}): ' @@ -169,6 +187,11 @@ class FfmpegSessionManager { // latence pure entre la disponibilité de la playlist et sa réponse. await Future.delayed(const Duration(milliseconds: 100)); } + // Le délai est écoulé : mieux vaut servir ce qui existe que rien. + if (playlistFile.existsSync() && + playlistFile.readAsStringSync().contains('.ts')) { + return (ready: true, error: null); + } return (ready: false, error: 'Timeout waiting for transcoder'); } diff --git a/bin/utils/media_probe.dart b/bin/utils/media_probe.dart new file mode 100644 index 0000000..d1ee891 --- /dev/null +++ b/bin/utils/media_probe.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +/// Inspection des fichiers média locaux (enregistrements) via ffprobe. +/// +/// Les résultats sont mémorisés tant que le fichier ne change pas : une +/// liste d'enregistrements est rafraîchie toutes les 5 s pendant une capture, +/// et la playlist de lecture est rechargée en continu — sans cache, ffprobe +/// tournerait en boucle. +class MediaProbe { + MediaProbe._(); + + static final Map _cache = {}; + + /// Binaire ffprobe : l'image Docker l'installe dans `/usr/local/bin` + /// à côté de FFmpeg ; `FFPROBE_PATH` permet de le surcharger. + static String ffprobePath() { + final fromEnv = Platform.environment['FFPROBE_PATH']; + if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv; + if (Platform.isLinux && File('/usr/local/bin/ffprobe').existsSync()) { + return '/usr/local/bin/ffprobe'; + } + return 'ffprobe'; + } + + /// Durée du média en secondes, ou `null` si elle n'est pas mesurable + /// (ffprobe absent, fichier introuvable, conteneur sans durée). + static Future duration(String path) async { + final value = await _probe( + path, + field: 'duration', + args: const [ + '-show_entries', 'format=duration', + ], + ); + final seconds = double.tryParse(value ?? ''); + if (seconds == null || !seconds.isFinite || seconds <= 0) return null; + return seconds; + } + + /// Codec de la première piste vidéo (`h264`, `hevc`, `mpeg2video`…), + /// ou `null` si indéterminable. + static Future videoCodec(String path) async { + final value = await _probe( + path, + field: 'videoCodec', + args: const [ + '-select_streams', 'v:0', + '-show_entries', 'stream=codec_name', + ], + ); + return (value == null || value.isEmpty) ? null : value.toLowerCase(); + } + + /// Exécute ffprobe une fois par (fichier, champ) et mémorise le résultat + /// tant que taille et date de modification sont inchangées. + static Future _probe( + String path, { + required String field, + required List args, + }) async { + FileStat stat; + try { + stat = File(path).statSync(); + if (stat.type == FileSystemEntityType.notFound) return null; + } catch (_) { + return null; + } + + final key = '$field@$path'; + final cached = _cache[key]; + if (cached != null && cached.matches(stat)) return cached.value; + + try { + final result = await Process.run(ffprobePath(), [ + '-v', 'error', + ...args, + '-of', 'default=noprint_wrappers=1:nokey=1', + path, + ]); + // ffprobe imprime une ligne par flux : seule la première nous intéresse. + final output = (result.stdout as String).trim().split('\n').first.trim(); + if (output.isEmpty || output == 'N/A') return null; + _cache[key] = _CacheEntry(stat, output); + return output; + } catch (_) { + return null; + } + } +} + +class _CacheEntry { + final int size; + final int mtimeMs; + final String value; + + _CacheEntry(FileStat stat, this.value) + : size = stat.size, + mtimeMs = stat.modified.millisecondsSinceEpoch; + + bool matches(FileStat stat) => + stat.size == size && + stat.modified.millisecondsSinceEpoch == mtimeMs; +} diff --git a/web/player.html b/web/player.html index f84ed7d..6de6404 100644 --- a/web/player.html +++ b/web/player.html @@ -96,35 +96,60 @@ playOverlay.style.display = 'none'; } - var player = new XFPlayer({ - video: video, - logPrefix: '[Player]', - onLoading: showLoading, - onReady: function () { - hideLoading(); - playOverlay.style.display = 'none'; - }, - onError: showError, - onBlocked: function () { - hideLoading(); - playOverlay.style.display = 'flex'; - }, - onProfileChange: function (name) { - // Retour visuel discret : l'utilisateur comprend pourquoi - // la latence augmente légèrement après des coupures. - loadingHint.textContent = - name === 'safe' ? 'Réseau instable — buffer maximal' - : 'Réseau instable — buffer élargi'; - } - }); + // Un enregistrement ou un film démarre une session FFmpeg côté + // serveur : le premier essai peut tomber pendant que l'encodeur + // se met en route. Le player mobile réessayait déjà seul ; ici + // l'utilisateur devait relancer la lecture à la main. + var retryCount = 0; + var maxRetries = 3; + var player = null; + + function buildPlayer() { + if (player) { try { player.destroy(); } catch (e) {} } + + player = new XFPlayer({ + video: video, + logPrefix: '[Player]', + onLoading: showLoading, + onReady: function () { + hideLoading(); + retryCount = 0; + playOverlay.style.display = 'none'; + }, + onError: function (msg) { + if (retryCount < maxRetries) { + retryCount++; + showLoading( + 'Préparation du flux ' + retryCount + '/' + maxRetries + ); + setTimeout(buildPlayer, 1500); + } else { + showError(msg); + } + }, + onBlocked: function () { + hideLoading(); + playOverlay.style.display = 'flex'; + }, + onProfileChange: function (name) { + // Retour visuel discret : l'utilisateur comprend pourquoi + // la latence augmente légèrement après des coupures. + loadingHint.textContent = + name === 'safe' ? 'Réseau instable — buffer maximal' + : 'Réseau instable — buffer élargi'; + } + }); + + player.start(); + window.xfPlayer = player; + } playOverlay.addEventListener('click', function () { video.play(); playOverlay.style.display = 'none'; }); - player.start(); - window.xfPlayer = player; + buildPlayer(); })(); diff --git a/web/xf-player-core.js b/web/xf-player-core.js index 3b16e91..f408ff5 100644 --- a/web/xf-player-core.js +++ b/web/xf-player-core.js @@ -181,8 +181,19 @@ this._destroyed = false; this._reportedTime = 0; this._hlsDuration = 0; + + // Écouteurs posés par cette instance, retirés à la destruction : sans + // ça, une relance après échec laisserait l'ancienne instance réagir aux + // commandes du parent et republier des positions périmées. + this._listeners = []; } + /** addEventListener + mémorisation pour un retrait propre. */ + XFPlayer.prototype._on = function (target, type, handler, options) { + target.addEventListener(type, handler, options); + this._listeners.push({ target: target, type: type, handler: handler }); + }; + XFPlayer.prototype.log = function (m) { if (global.console && console.log) console.log(this.logPrefix + ' ' + m); }; @@ -231,6 +242,8 @@ XFPlayer.prototype._hlsConfig = function () { var prof = this.profile.hls; + var isLive = this.type === 'live'; + return { enableWorker: true, lowLatencyMode: false, @@ -248,15 +261,39 @@ startFragPrefetch: true, // précharge le 1er fragment pendant le parse testBandwidth: false, // pas de mesure préalable : on joue tout de suite + // ---- Début de lecture ---- + // Un enregistrement (ou un film) est transcodé au fil de l'eau : sa + // playlist n'a pas encore d'`EXT-X-ENDLIST`, donc hls.js la considère + // comme du direct. Sans consigne explicite il démarre alors au « bord + // du direct », c'est-à-dire collé au front d'encodage : la lecture + // partait au milieu du programme et se coupait aussitôt, faute de + // segments d'avance. `0` = au début, comme attendu d'un contenu + // à la demande. En direct, -1 conserve le démarrage au bord du direct. + startPosition: isLive ? -1 : (this.startTime > 0 ? this.startTime : 0), + // ---- Live ---- liveSyncDurationCount: prof.liveSyncDurationCount, - liveMaxLatencyDurationCount: prof.liveMaxLatencyDurationCount, - maxLiveSyncPlaybackRate: 1.15, + // Hors direct, ces deux réglages doivent être neutralisés : ils + // accélèrent la lecture et finissent par forcer un saut en avant pour + // « rattraper » un direct qui n'existe pas. + // 86400 segments = hors d'atteinte, sans recourir à Infinity dont + // hls.js fait ensuite des multiplications. + liveMaxLatencyDurationCount: isLive + ? prof.liveMaxLatencyDurationCount + : 86400, + maxLiveSyncPlaybackRate: isLive ? 1.15 : 1, // ---- Robustesse ---- nudgeOffset: 0.6, nudgeMaxRetries: 20, abrEwmaDefaultEstimate: 2000000, + // Hors direct, la première requête de playlist peut légitimement + // bloquer le temps que FFmpeg démarre et produise ses premiers + // segments. Le défaut de 10 s de hls.js transformait ce démarrage en + // « erreur de chargement » — il fallait relancer la lecture pour + // tomber sur une session déjà chaude. + manifestLoadingTimeOut: isLive ? 10000 : 45000, + levelLoadingTimeOut: isLive ? 10000 : 45000, manifestLoadingRetryDelay: 500, levelLoadingRetryDelay: 500, fragLoadingRetryDelay: 500, @@ -520,26 +557,26 @@ var self = this; var v = this.video; - v.addEventListener('loadedmetadata', function () { + this._on(v, 'loadedmetadata', function () { if (self.startTime > 0 && self.startTime < v.duration) { v.currentTime = self.startTime; } }); - v.addEventListener('playing', function () { + this._on(v, 'playing', function () { self._started = true; self.onReady(); self.send({ type: 'playback_status', status: 'playing' }); }); - v.addEventListener('pause', function () { + this._on(v, 'pause', function () { self.send({ type: 'playback_status', status: 'paused' }); }); - v.addEventListener('waiting', function () { self._noteStall(); }); - v.addEventListener('stalled', function () { self._noteStall(); }); + this._on(v, 'waiting', function () { self._noteStall(); }); + this._on(v, 'stalled', function () { self._noteStall(); }); - v.addEventListener('ended', function () { + this._on(v, 'ended', function () { self.send({ type: 'playback_ended', duration: self._totalDuration() @@ -550,7 +587,7 @@ // portion pas encore navigable de la barre de progression. `progress` // se déclenche à chaque bloc reçu — bridé à 2 s, sinon chaque segment // provoquerait une reconstruction de l'interface Flutter. - v.addEventListener('progress', function () { + this._on(v, 'progress', function () { if (!self._started) return; var now = Date.now(); if (now - (self._lastProgressSent || 0) < 2000) return; @@ -558,16 +595,16 @@ self._sendPosition(); }); - v.addEventListener('seeked', function () { self._sendPosition(); }); + this._on(v, 'seeked', function () { self._sendPosition(); }); // Remontée de position (5 s) — même contrat qu'avant. this._posTimer = setInterval(function () { self._reportPosition(); }, 5000); // Activité utilisateur → masquage auto des contrôles côté Flutter. function activity() { self.send({ type: 'user_activity' }); } - document.addEventListener('mousemove', activity, { passive: true }); - document.addEventListener('touchstart', activity, { passive: true }); - document.addEventListener('click', activity, { passive: true }); + this._on(document, 'mousemove', activity, { passive: true }); + this._on(document, 'touchstart', activity, { passive: true }); + this._on(document, 'click', activity, { passive: true }); }; /** Fin de la plage réellement navigable, en temps média. */ @@ -668,7 +705,7 @@ XFPlayer.prototype._wireParentMessages = function () { var self = this; - global.addEventListener('message', function (event) { + this._on(global, 'message', function (event) { // N'accepter que les commandes émises par notre propre origine // (le côté Flutter filtre déjà les messages entrants de la même façon). if (event.origin !== global.location.origin) return; @@ -705,6 +742,10 @@ XFPlayer.prototype.destroy = function () { this._destroyed = true; clearInterval(this._posTimer); + this._listeners.forEach(function (l) { + try { l.target.removeEventListener(l.type, l.handler); } catch (e) {} + }); + this._listeners = []; try { if (this.hls) this.hls.destroy(); } catch (e) {} try { if (this.mpegts) {