mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
Merge pull request #17 from R0m1k3/fix/repli-gpu-cpu
Lecture : repli CPU quand le GPU sature, plus de spinner infini
This commit is contained in:
5 files changed
+303
-37
No files matched your search
+108
-33
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import '../database/database.dart';
|
import '../database/database.dart';
|
||||||
import '../models/playlist_config.dart';
|
import '../models/playlist_config.dart';
|
||||||
import '../services/ffmpeg_session_manager.dart';
|
import '../services/ffmpeg_session_manager.dart';
|
||||||
|
import '../services/gpu_fallback.dart';
|
||||||
import '../utils/log_redactor.dart';
|
import '../utils/log_redactor.dart';
|
||||||
import '../utils/media_probe.dart';
|
import '../utils/media_probe.dart';
|
||||||
import '../utils/stream_pipe.dart';
|
import '../utils/stream_pipe.dart';
|
||||||
@@ -17,6 +18,9 @@ final Directory _hlsTempDir =
|
|||||||
/// Global FFmpeg session registry (initialized in [initStreaming]).
|
/// Global FFmpeg session registry (initialized in [initStreaming]).
|
||||||
final FfmpegSessionManager sessionManager = FfmpegSessionManager(_hlsTempDir);
|
final FfmpegSessionManager sessionManager = FfmpegSessionManager(_hlsTempDir);
|
||||||
|
|
||||||
|
/// Pannes GPU récentes, partagées par toutes les routes de transcodage.
|
||||||
|
final GpuHealth _gpuHealth = GpuHealth();
|
||||||
|
|
||||||
/// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable)
|
/// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable)
|
||||||
String _getFFmpegPath() {
|
String _getFFmpegPath() {
|
||||||
if (Platform.isWindows) {
|
if (Platform.isWindows) {
|
||||||
@@ -181,6 +185,71 @@ Map<String, String> _segmentHeaders({int maxAge = 60}) => {
|
|||||||
'Cache-Control': 'max-age=$maxAge',
|
'Cache-Control': 'max-age=$maxAge',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
typedef _SessionAttempt = ({FfmpegSession? session, bool ready, String? error});
|
||||||
|
|
||||||
|
/// Démarre (ou récupère) la session [id] et attend sa playlist.
|
||||||
|
///
|
||||||
|
/// Un `Process.start` qui échoue (serveur à court de processus ou de
|
||||||
|
/// mémoire) devient un échec ordinaire au lieu d'une exception non
|
||||||
|
/// rattrapée qui remontait en 500.
|
||||||
|
Future<_SessionAttempt> _runSession({
|
||||||
|
required String id,
|
||||||
|
required bool isLive,
|
||||||
|
required List<String> Function(Directory dir) argsBuilder,
|
||||||
|
int minSegments = 1,
|
||||||
|
}) async {
|
||||||
|
final FfmpegSession session;
|
||||||
|
try {
|
||||||
|
session = await sessionManager.getOrStart(
|
||||||
|
id: id,
|
||||||
|
isLive: isLive,
|
||||||
|
ffmpegPath: _getFFmpegPath(),
|
||||||
|
argsBuilder: argsBuilder,
|
||||||
|
);
|
||||||
|
} on ProcessException catch (e) {
|
||||||
|
return (session: null, ready: false, error: 'FFmpeg start failed: $e');
|
||||||
|
}
|
||||||
|
final outcome =
|
||||||
|
await sessionManager.waitForPlaylist(session, minSegments: minSegments);
|
||||||
|
return (session: session, ready: outcome.ready, error: outcome.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comme [_runSession], avec repli sur le CPU si l'encodage GPU échoue.
|
||||||
|
///
|
||||||
|
/// Le repli garde le même identifiant de session : les requêtes de segments
|
||||||
|
/// qui suivent tombent sur la session CPU sans que le lecteur s'en aperçoive.
|
||||||
|
/// Une panne GPU fait aussi partir les sessions suivantes directement sur le
|
||||||
|
/// CPU pendant le délai de [GpuHealth], au lieu d'échouer une à une.
|
||||||
|
Future<_SessionAttempt> _runWithGpuFallback({
|
||||||
|
required String id,
|
||||||
|
required bool isLive,
|
||||||
|
required bool wantGpu,
|
||||||
|
required List<String> Function(bool gpu) buildArgs,
|
||||||
|
int minSegments = 1,
|
||||||
|
}) async {
|
||||||
|
final gpu = wantGpu && _gpuHealth.available;
|
||||||
|
var attempt = await _runSession(
|
||||||
|
id: id,
|
||||||
|
isLive: isLive,
|
||||||
|
argsBuilder: (_) => buildArgs(gpu),
|
||||||
|
minSegments: minSegments,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!attempt.ready && gpu && isGpuFailure(attempt.error)) {
|
||||||
|
_gpuHealth.markFailed();
|
||||||
|
print('[GPU] $id : encodage GPU refusé '
|
||||||
|
'(${LogRedactor.redactUrl(attempt.error ?? '')}) — repli sur le CPU');
|
||||||
|
sessionManager.killSession(id);
|
||||||
|
attempt = await _runSession(
|
||||||
|
id: id,
|
||||||
|
isLive: isLive,
|
||||||
|
argsBuilder: (_) => buildArgs(false),
|
||||||
|
minSegments: minSegments,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 1. LIVE TV HANDLER (FFmpeg HLS + direct TS proxy)
|
// 1. LIVE TV HANDLER (FFmpeg HLS + direct TS proxy)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -310,13 +379,13 @@ Handler createLiveStreamHandler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final session = await sessionManager.getOrStart(
|
final result = await _runWithGpuFallback(
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
isLive: true,
|
isLive: true,
|
||||||
ffmpegPath: _getFFmpegPath(),
|
wantGpu: useNvidiaGpu && quality != 'source',
|
||||||
argsBuilder: (dir) => [
|
buildArgs: (gpu) => [
|
||||||
'-hide_banner', '-loglevel', 'warning',
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'],
|
if (gpu) ...['-hwaccel', 'cuda'],
|
||||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||||
'-reconnect', '1', '-reconnect_streamed', '1',
|
'-reconnect', '1', '-reconnect_streamed', '1',
|
||||||
'-reconnect_at_eof', '1',
|
'-reconnect_at_eof', '1',
|
||||||
@@ -330,7 +399,7 @@ Handler createLiveStreamHandler(
|
|||||||
'-probesize', '1000000',
|
'-probesize', '1000000',
|
||||||
'-analyzeduration', '1000000',
|
'-analyzeduration', '1000000',
|
||||||
'-i', targetUrl,
|
'-i', targetUrl,
|
||||||
..._liveVideoArgs(quality, useNvidiaGpu),
|
..._liveVideoArgs(quality, gpu),
|
||||||
..._audioArgs(withFilters: false),
|
..._audioArgs(withFilters: false),
|
||||||
// HLS sliding window: 10 x 2s segments (lower live latency than the
|
// HLS sliding window: 10 x 2s segments (lower live latency than the
|
||||||
// previous 20-segment window while still safe for iOS).
|
// previous 20-segment window while still safe for iOS).
|
||||||
@@ -347,8 +416,8 @@ Handler createLiveStreamHandler(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await sessionManager.waitForPlaylist(session);
|
final session = result.session;
|
||||||
if (!result.ready) {
|
if (!result.ready || session == null) {
|
||||||
sessionManager.killSession(sessionId);
|
sessionManager.killSession(sessionId);
|
||||||
return Response(502, body: 'Live transcoder failed: ${result.error}');
|
return Response(502, body: 'Live transcoder failed: ${result.error}');
|
||||||
}
|
}
|
||||||
@@ -400,7 +469,9 @@ Handler createLiveStreamHandler(
|
|||||||
'[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}',
|
'[Live Turbo] $streamId: ${LogRedactor.redactUrl(targetUrl)}',
|
||||||
);
|
);
|
||||||
|
|
||||||
final process = await Process.start(_getFFmpegPath(), [
|
final Process process;
|
||||||
|
try {
|
||||||
|
process = await Process.start(_getFFmpegPath(), [
|
||||||
'-hide_banner', '-loglevel', 'warning',
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||||
'-reconnect', '1', '-reconnect_streamed', '1',
|
'-reconnect', '1', '-reconnect_streamed', '1',
|
||||||
@@ -420,6 +491,12 @@ Handler createLiveStreamHandler(
|
|||||||
'-muxdelay', '0', '-muxpreload', '0',
|
'-muxdelay', '0', '-muxpreload', '0',
|
||||||
'-f', 'mpegts', 'pipe:1',
|
'-f', 'mpegts', 'pipe:1',
|
||||||
]);
|
]);
|
||||||
|
} on ProcessException catch (e) {
|
||||||
|
// Serveur saturé (plus de processus ou de mémoire) : un 503 que le
|
||||||
|
// lecteur sait traiter, plutôt qu'une exception qui remonte en 500.
|
||||||
|
print('[Live Turbo] $streamId : FFmpeg n\'a pas démarré ($e)');
|
||||||
|
return Response(503, body: 'FFmpeg start failed');
|
||||||
|
}
|
||||||
|
|
||||||
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
// Journaliser les erreurs FFmpeg (redactées) sans bloquer le flux.
|
||||||
process.stderr.transform(const SystemEncoding().decoder).listen((line) {
|
process.stderr.transform(const SystemEncoding().decoder).listen((line) {
|
||||||
@@ -535,13 +612,13 @@ Handler createVodStreamHandler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final session = await sessionManager.getOrStart(
|
final result = await _runWithGpuFallback(
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
isLive: false,
|
isLive: false,
|
||||||
ffmpegPath: _getFFmpegPath(),
|
wantGpu: useNvidiaGpu && quality != 'source',
|
||||||
argsBuilder: (dir) => [
|
buildArgs: (gpu) => [
|
||||||
'-hide_banner', '-loglevel', 'warning',
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'],
|
if (gpu) ...['-hwaccel', 'cuda'],
|
||||||
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
|
||||||
'-reconnect', '1',
|
'-reconnect', '1',
|
||||||
'-reconnect_at_eof', '1',
|
'-reconnect_at_eof', '1',
|
||||||
@@ -555,7 +632,7 @@ Handler createVodStreamHandler(
|
|||||||
'-analyzeduration', '2000000',
|
'-analyzeduration', '2000000',
|
||||||
'-probesize', '5000000',
|
'-probesize', '5000000',
|
||||||
'-i', targetUrl,
|
'-i', targetUrl,
|
||||||
..._vodVideoArgs(quality, useNvidiaGpu),
|
..._vodVideoArgs(quality, gpu),
|
||||||
..._audioArgs(withFilters: quality != 'source'),
|
..._audioArgs(withFilters: quality != 'source'),
|
||||||
'-f', 'hls',
|
'-f', 'hls',
|
||||||
'-hls_time', '4',
|
'-hls_time', '4',
|
||||||
@@ -570,8 +647,8 @@ Handler createVodStreamHandler(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await sessionManager.waitForPlaylist(session);
|
final session = result.session;
|
||||||
if (!result.ready) {
|
if (!result.ready || session == null) {
|
||||||
sessionManager.killSession(sessionId);
|
sessionManager.killSession(sessionId);
|
||||||
return Response(502, body: 'VOD transcoder failed: ${result.error}');
|
return Response(502, body: 'VOD transcoder failed: ${result.error}');
|
||||||
}
|
}
|
||||||
@@ -677,9 +754,8 @@ Handler createRecordingStreamHandler(
|
|||||||
final videoCodec = await MediaProbe.videoCodec(targetUrl);
|
final videoCodec = await MediaProbe.videoCodec(targetUrl);
|
||||||
final canCopyVideo = videoCodec == 'h264';
|
final canCopyVideo = videoCodec == 'h264';
|
||||||
|
|
||||||
List<String> buildArgs({required bool copyVideo}) {
|
List<String> buildArgs({required bool copyVideo, required bool gpu}) {
|
||||||
final useNvidiaGpu =
|
final useNvidiaGpu = !copyVideo && gpu;
|
||||||
!copyVideo && (isGpuEnabled?.call() ?? _isNvidiaGpuEnabled());
|
|
||||||
return [
|
return [
|
||||||
'-hide_banner', '-loglevel', 'warning',
|
'-hide_banner', '-loglevel', 'warning',
|
||||||
if (useNvidiaGpu) ...['-hwaccel', 'cuda'],
|
if (useNvidiaGpu) ...['-hwaccel', 'cuda'],
|
||||||
@@ -724,9 +800,7 @@ Handler createRecordingStreamHandler(
|
|||||||
/// au démarrage, la playlist étant servie dès le premier segment. En
|
/// au démarrage, la playlist étant servie dès le premier segment. En
|
||||||
/// copie vidéo elle est produite en une fraction de seconde, elle ne
|
/// copie vidéo elle est produite en une fraction de seconde, elle ne
|
||||||
/// coûte donc que sur un ré-encodage.
|
/// coûte donc que sur un ré-encodage.
|
||||||
Future<({FfmpegSession session, bool ready, String? error})> run(
|
Future<_SessionAttempt> run(bool copyVideo) {
|
||||||
bool copyVideo,
|
|
||||||
) async {
|
|
||||||
if (!sessionManager.contains(sessionId)) {
|
if (!sessionManager.contains(sessionId)) {
|
||||||
print(
|
print(
|
||||||
'[Recording] $sessionId : '
|
'[Recording] $sessionId : '
|
||||||
@@ -734,18 +808,20 @@ Handler createRecordingStreamHandler(
|
|||||||
'(codec source : ${videoCodec ?? 'inconnu'})',
|
'(codec source : ${videoCodec ?? 'inconnu'})',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final session = await sessionManager.getOrStart(
|
if (copyVideo) {
|
||||||
|
return _runSession(
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
isLive: false,
|
isLive: false,
|
||||||
ffmpegPath: _getFFmpegPath(),
|
argsBuilder: (_) => buildArgs(copyVideo: true, gpu: false),
|
||||||
argsBuilder: (dir) => buildArgs(copyVideo: copyVideo),
|
minSegments: 3,
|
||||||
);
|
);
|
||||||
final outcome =
|
}
|
||||||
await sessionManager.waitForPlaylist(session, minSegments: 3);
|
return _runWithGpuFallback(
|
||||||
return (
|
id: sessionId,
|
||||||
session: session,
|
isLive: false,
|
||||||
ready: outcome.ready,
|
wantGpu: isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(),
|
||||||
error: outcome.error,
|
buildArgs: (gpu) => buildArgs(copyVideo: false, gpu: gpu),
|
||||||
|
minSegments: 3,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,14 +839,13 @@ Handler createRecordingStreamHandler(
|
|||||||
attempt = await run(false);
|
attempt = await run(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!attempt.ready) {
|
final session = attempt.session;
|
||||||
|
if (!attempt.ready || session == null) {
|
||||||
sessionManager.killSession(sessionId);
|
sessionManager.killSession(sessionId);
|
||||||
return Response(502,
|
return Response(502,
|
||||||
body: 'Recording transcoder failed: ${attempt.error}');
|
body: 'Recording transcoder failed: ${attempt.error}');
|
||||||
}
|
}
|
||||||
|
|
||||||
final session = attempt.session;
|
|
||||||
|
|
||||||
session.touch();
|
session.touch();
|
||||||
// Ménage des offsets abandonnés (aller-retours dans la barre de
|
// Ménage des offsets abandonnés (aller-retours dans la barre de
|
||||||
// progression), une fois la nouvelle session confirmée démarrée.
|
// progression), une fois la nouvelle session confirmée démarrée.
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/// Repli GPU → CPU des transcodages.
|
||||||
|
///
|
||||||
|
/// Quand la VRAM est saturée (plusieurs sessions NVENC, un autre processus
|
||||||
|
/// sur la carte) ou que le GPU dépasse son quota de sessions d'encodage,
|
||||||
|
/// FFmpeg échoue à l'ouverture de l'encodeur et meurt en une seconde. Sans
|
||||||
|
/// repli, le lecteur recevait un 502, relançait… et retombait sur le même
|
||||||
|
/// GPU saturé : le flux restait illisible tant que la carte était pleine.
|
||||||
|
|
||||||
|
/// Signatures FFmpeg d'un échec imputable au GPU NVIDIA (NVENC/NVDEC/CUDA).
|
||||||
|
///
|
||||||
|
/// N'est consulté que pour une session lancée sur le GPU : « opening
|
||||||
|
/// encoder » y désigne donc forcément h264_nvenc.
|
||||||
|
final _gpuFailurePattern = RegExp(
|
||||||
|
r'nvenc|cuda|cuvid|nvdec|OpenEncodeSession|No capable devices|'
|
||||||
|
r'out of memory|Device creation failed|hwaccel|opening encoder',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Vrai si [error] (stderr récent de FFmpeg) trahit une panne GPU, et non
|
||||||
|
/// une source injoignable ou un délai dépassé — qu'un repli CPU ne
|
||||||
|
/// réglerait pas, et qui doublerait inutilement la charge d'un serveur déjà
|
||||||
|
/// occupé.
|
||||||
|
bool isGpuFailure(String? error) =>
|
||||||
|
error != null && _gpuFailurePattern.hasMatch(error);
|
||||||
|
|
||||||
|
/// Mémorise une panne GPU récente pour que les sessions suivantes partent
|
||||||
|
/// directement sur le CPU, au lieu de payer chacune un échec NVENC avant
|
||||||
|
/// leur repli.
|
||||||
|
class GpuHealth {
|
||||||
|
final Duration cooldown;
|
||||||
|
final DateTime Function() _now;
|
||||||
|
DateTime? _unavailableUntil;
|
||||||
|
|
||||||
|
GpuHealth({
|
||||||
|
this.cooldown = const Duration(minutes: 2),
|
||||||
|
DateTime Function()? now,
|
||||||
|
}) : _now = now ?? DateTime.now;
|
||||||
|
|
||||||
|
bool get available =>
|
||||||
|
_unavailableUntil == null || !_now().isBefore(_unavailableUntil!);
|
||||||
|
|
||||||
|
void markFailed() => _unavailableUntil = _now().add(cooldown);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:test/test.dart';
|
||||||
|
import '../services/gpu_fallback.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('isGpuFailure', () {
|
||||||
|
test('detects NVENC out-of-memory', () {
|
||||||
|
expect(
|
||||||
|
isGpuFailure(
|
||||||
|
'FFmpeg exited (1): [h264_nvenc @ 0x55] OpenEncodeSessionEx failed: '
|
||||||
|
'out of memory (10)',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects missing NVENC device', () {
|
||||||
|
expect(
|
||||||
|
isGpuFailure('[h264_nvenc @ 0x1] No capable devices found'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects CUDA errors', () {
|
||||||
|
expect(
|
||||||
|
isGpuFailure('cu->cuInit(0) failed -> CUDA_ERROR_OUT_OF_MEMORY'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects encoder opening failure', () {
|
||||||
|
expect(
|
||||||
|
isGpuFailure(
|
||||||
|
'Error while opening encoder for output stream #0:0 - maybe '
|
||||||
|
'incorrect parameters such as bit_rate, rate, width or height',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores upstream failures', () {
|
||||||
|
expect(
|
||||||
|
isGpuFailure(
|
||||||
|
'FFmpeg exited (1): http://x/live/u/p/1.ts: Server returned 404 '
|
||||||
|
'Not Found',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores timeouts and null', () {
|
||||||
|
expect(isGpuFailure('Timeout waiting for transcoder'), isFalse);
|
||||||
|
expect(isGpuFailure(null), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('GpuHealth', () {
|
||||||
|
test('available until a failure, then again after cooldown', () {
|
||||||
|
var now = DateTime.utc(2026, 10, 8, 20, 0);
|
||||||
|
final health = GpuHealth(
|
||||||
|
cooldown: const Duration(minutes: 2),
|
||||||
|
now: () => now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(health.available, isTrue);
|
||||||
|
|
||||||
|
health.markFailed();
|
||||||
|
expect(health.available, isFalse);
|
||||||
|
|
||||||
|
now = now.add(const Duration(minutes: 1, seconds: 59));
|
||||||
|
expect(health.available, isFalse);
|
||||||
|
|
||||||
|
now = now.add(const Duration(seconds: 1));
|
||||||
|
expect(health.available, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+27
-4
@@ -90,15 +90,36 @@
|
|||||||
playOverlay.style.display = 'none';
|
playOverlay.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
var player = new XFPlayer({
|
// Un serveur chargé peut rater le démarrage du transcodeur : on
|
||||||
|
// relance un lecteur neuf avant d'afficher l'erreur, comme le
|
||||||
|
// font déjà player.html et player_mobile.html.
|
||||||
|
var retryCount = 0;
|
||||||
|
var maxRetries = 3;
|
||||||
|
var player = null;
|
||||||
|
|
||||||
|
function buildPlayer() {
|
||||||
|
if (player) { try { player.destroy(); } catch (e) {} }
|
||||||
|
|
||||||
|
player = new XFPlayer({
|
||||||
video: video,
|
video: video,
|
||||||
logPrefix: '[LitePlayer]',
|
logPrefix: '[LitePlayer]',
|
||||||
onLoading: showLoading,
|
onLoading: showLoading,
|
||||||
onReady: function () {
|
onReady: function () {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
|
retryCount = 0;
|
||||||
playOverlay.style.display = 'none';
|
playOverlay.style.display = 'none';
|
||||||
},
|
},
|
||||||
onError: showError,
|
onError: function (msg) {
|
||||||
|
if (retryCount < maxRetries) {
|
||||||
|
retryCount++;
|
||||||
|
showLoading(
|
||||||
|
'Reconnexion ' + retryCount + '/' + maxRetries
|
||||||
|
);
|
||||||
|
setTimeout(buildPlayer, 1500);
|
||||||
|
} else {
|
||||||
|
showError(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
onBlocked: function () {
|
onBlocked: function () {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
playOverlay.style.display = 'flex';
|
playOverlay.style.display = 'flex';
|
||||||
@@ -109,6 +130,9 @@
|
|||||||
: 'Réseau instable — buffer élargi';
|
: 'Réseau instable — buffer élargi';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
window.xfPlayer = player;
|
||||||
|
player.start();
|
||||||
|
}
|
||||||
|
|
||||||
playOverlay.addEventListener('click', function () {
|
playOverlay.addEventListener('click', function () {
|
||||||
video.play();
|
video.play();
|
||||||
@@ -123,8 +147,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
player.start();
|
buildPlayer();
|
||||||
window.xfPlayer = player;
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -55,6 +55,9 @@
|
|||||||
|
|
||||||
var ORDER = ['fast', 'balanced', 'safe'];
|
var ORDER = ['fast', 'balanced', 'safe'];
|
||||||
|
|
||||||
|
// Erreurs MPEG-TS tolérées sur 60 s avant de basculer sur HLS.
|
||||||
|
var MPEGTS_MAX_ERRORS = 3;
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Chargement paresseux des libs (aucune requête inutile)
|
// Chargement paresseux des libs (aucune requête inutile)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -178,6 +181,7 @@
|
|||||||
this._started = false;
|
this._started = false;
|
||||||
this._stalls = 0;
|
this._stalls = 0;
|
||||||
this._stallTimes = [];
|
this._stallTimes = [];
|
||||||
|
this._mpegtsErrorTimes = [];
|
||||||
this._destroyed = false;
|
this._destroyed = false;
|
||||||
this._reportedTime = 0;
|
this._reportedTime = 0;
|
||||||
this._hlsDuration = 0;
|
this._hlsDuration = 0;
|
||||||
@@ -317,10 +321,15 @@
|
|||||||
self.hls = hls;
|
self.hls = hls;
|
||||||
global.hlsInstance = hls; // compat : code existant qui inspecte l'instance
|
global.hlsInstance = hls; // compat : code existant qui inspecte l'instance
|
||||||
|
|
||||||
|
// Faux tant que la première playlist n'a pas été obtenue : voir le
|
||||||
|
// traitement des erreurs réseau plus bas.
|
||||||
|
var manifestParsed = false;
|
||||||
|
|
||||||
hls.loadSource(self.url);
|
hls.loadSource(self.url);
|
||||||
hls.attachMedia(self.video);
|
hls.attachMedia(self.video);
|
||||||
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||||
|
manifestParsed = true;
|
||||||
// Démarrage immédiat : on ne sonde plus le buffer toutes les 200 ms.
|
// Démarrage immédiat : on ne sonde plus le buffer toutes les 200 ms.
|
||||||
// Si le réseau ne suit pas, l'escalade de profil s'en chargera.
|
// Si le réseau ne suit pas, l'escalade de profil s'en chargera.
|
||||||
self._attemptPlay();
|
self._attemptPlay();
|
||||||
@@ -337,6 +346,15 @@
|
|||||||
if (!d.fatal) return;
|
if (!d.fatal) return;
|
||||||
self.log('Erreur HLS fatale : ' + d.details);
|
self.log('Erreur HLS fatale : ' + d.details);
|
||||||
if (d.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
if (d.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||||
|
// Playlist jamais obtenue (transcodeur en échec → 502, serveur trop
|
||||||
|
// chargé → délai dépassé), après les relances internes de hls.js.
|
||||||
|
// `startLoad()` ne relance pas ce chargement-là : le lecteur
|
||||||
|
// restait muet sur un spinner infini. On remonte l'erreur pour que
|
||||||
|
// la page relance un lecteur neuf ou l'affiche.
|
||||||
|
if (!manifestParsed) {
|
||||||
|
self.onError('Flux indisponible : le serveur n\'a pas pu le préparer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
self._escalate('erreur réseau');
|
self._escalate('erreur réseau');
|
||||||
hls.startLoad();
|
hls.startLoad();
|
||||||
} else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
} else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||||
@@ -446,6 +464,37 @@
|
|||||||
player.on(mpegts.Events.ERROR, function (type, detail) {
|
player.on(mpegts.Events.ERROR, function (type, detail) {
|
||||||
self.log('Erreur MPEG-TS : ' + type + ' / ' + detail);
|
self.log('Erreur MPEG-TS : ' + type + ' / ' + detail);
|
||||||
if (self._destroyed) return;
|
if (self._destroyed) return;
|
||||||
|
|
||||||
|
// Chaque recréation relance un FFmpeg côté serveur : sur un serveur
|
||||||
|
// déjà saturé, recréer sans fin toutes les 1,2 s ne faisait
|
||||||
|
// qu'aggraver la charge. Au-delà de quelques échecs rapprochés, on
|
||||||
|
// passe par la route HLS, qui renvoie un vrai code d'erreur.
|
||||||
|
var now = Date.now();
|
||||||
|
self._mpegtsErrorTimes = self._mpegtsErrorTimes.filter(function (t) {
|
||||||
|
return now - t < 60000;
|
||||||
|
});
|
||||||
|
self._mpegtsErrorTimes.push(now);
|
||||||
|
if (self._mpegtsErrorTimes.length > MPEGTS_MAX_ERRORS) {
|
||||||
|
try {
|
||||||
|
player.unload();
|
||||||
|
player.detachMediaElement();
|
||||||
|
player.destroy();
|
||||||
|
} catch (e) {}
|
||||||
|
self.mpegts = null;
|
||||||
|
|
||||||
|
var hlsUrl = self._hlsEquivalent(self.url);
|
||||||
|
if (!hlsUrl) {
|
||||||
|
self.onError('Flux indisponible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.log('Erreurs MPEG-TS répétées — bascule HLS');
|
||||||
|
self.onLoading('Ouverture du flux');
|
||||||
|
self.url = hlsUrl;
|
||||||
|
if (self._canPlayNativeHls()) self._playDirect(hlsUrl);
|
||||||
|
else self._startHls();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self._escalate('erreur MPEG-TS');
|
self._escalate('erreur MPEG-TS');
|
||||||
// Recréation propre avec le nouveau profil.
|
// Recréation propre avec le nouveau profil.
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
|
|||||||
Reference in new issue
Block a user