Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 f82c70ae05 Lecture TV : reconstituer la réserve en lisant à x0,97
Télémétrie (session olfp14) : le panneau n'avait envoyé que ~18 s
d'avance au démarrage ; la réserve est restée à 13-20 s et un silence du
panneau a fini en coupure de 5,5 s. Le lecteur ne fabriquait pas de
réserve : il gardait seulement celle offerte par le panneau.

Régulateur de vitesse unique pour le direct MPEG-TS, chaque seconde :
sous 20 s d'avance, x0,97 (hauteur du son préservée) jusqu'à 25 s ;
au-delà de 35 s (rafale de rattrapage), x1,1 jusqu'à 28 s. Seuils
d'entrée/sortie distincts : pas d'oscillation. liveSync de mpegts.js
désactivé : il ne sait qu'accélérer et remettait la vitesse à 1 sous sa
cible, ce qui annulerait le ralenti.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:11:59 +02:00
MichaelandClaude Opus 5.5 6733f80ffd Lecture TV : plus de saut de 20 s après un silence du panneau
Télémétrie (session de 25 min avec la réserve de 25 s) : un silence du
panneau a été absorbé sans coupure (avance 27 → 14,7 s), mais à la
reprise le panneau renvoie d'un coup tout le retard. L'avance dépassait
alors le seuil de rattrapage (45 s) et le lecteur sautait 20 s de
programme — deux fois en une minute.

Saut seulement au-delà de 90 s de retard ; liveSync (x1,1 au-delà de
35 s) résorbe l'excédent en douceur. Vidéo déjà vue purgée plus tôt
(30 s) pour rester sous le quota du SourceBuffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:41:43 +02:00
MichaelandClaude Opus 5.5 0539ad857f Lecture TV : traverser les silences du panneau sans figer
Télémétrie d'un freeze en prod : le panneau s'est tu ~15-20 s sur une
connexion restée ouverte (aucune reconnexion FFmpeg dans les logs).
Le lecteur n'avait gardé que 11-16 s d'avance — il « consommait » à x1,1
celle que le panneau envoie au démarrage pour revenir à 12 s — puis,
après la coupure de 6,3 s, il repartait avec 1 s de stock et recoupait
aussitôt (1,7 s puis 2,0 s).

- réserve visée 25 s (liveSync 25/35, saut au-delà de 45 s) : ~25 s de
  retard sur le direct en échange de la traversée de ces silences
- après une coupure, attente de 4 s de réserve avant de relire (comme
  mpv cache-pause), 15 s au plus ; une pause utilisateur l'annule ; le
  lecteur lite affiche « Reconstitution de la réserve » au lieu du
  bouton lecture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:04:25 +02:00
3 changed files with 277 additions and 15 deletions

No files matched your search

+150 -5
View File
@@ -93,12 +93,32 @@ for (const profile of ['fast', 'balanced', 'safe']) {
});
}
test('live : le retard est résorbé en douceur (liveSync) plutôt que par sauts', () => {
/** Réglages du régulateur de vitesse (exposés par le moteur). */
function liveTuning() {
const window = {
addEventListener() {}, removeEventListener() {},
console: { log() {} },
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
parent: { postMessage() {} },
};
vm.runInContext(source, vm.createContext({
window, location: window.location,
document: { addEventListener() {}, removeEventListener() {} },
URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout,
}));
return window.XFPlayerLiveTuning;
}
test('live : un seul régulateur de vitesse (liveSync de mpegts.js désactivé)', () => {
// liveSync ne sait qu'accélérer et remet la vitesse à 1 sous sa cible :
// il annulerait le ralenti qui reconstitue la réserve.
const c = liveMpegtsConfig();
assert.equal(c.liveSync, true);
assert.ok(c.liveSyncPlaybackRate > 1 && c.liveSyncPlaybackRate <= 1.2);
assert.ok(c.liveSyncTargetLatency < c.liveSyncMaxLatency);
assert.ok(c.liveSyncMaxLatency <= c.liveBufferLatencyMaxLatency);
assert.notEqual(c.liveSync, true);
const t = liveTuning();
assert.ok(t.slowRate < 1 && t.slowRate >= 0.95, 'ralenti audible au-delà');
assert.ok(t.fastRate > 1 && t.fastRate <= 1.15);
assert.ok(t.lowAhead < t.targetAhead && t.targetAhead < t.highAhead);
assert.ok(t.highAhead < c.liveBufferLatencyMaxLatency);
});
test('télémétrie : une coupure (waiting → playing) est remontée avec sa durée', async () => {
@@ -149,6 +169,131 @@ test('télémétrie : une coupure (waiting → playing) est remontée avec sa du
assert.equal(sent[0].url, '/api/player-log');
});
// Freeze mesuré en prod (télémétrie) : le panneau s'est tu ~15-20 s sur une
// connexion restée ouverte, alors que le lecteur n'avait gardé que 11-16 s.
const PROVIDER_OUTAGE_S = 18;
test('live : la réserve visée couvre un silence du panneau de ~18 s', () => {
const c = liveMpegtsConfig();
const t = liveTuning();
// Le régulateur ramène l'avance vers la cible : c'est la réserve en
// régime établi. Elle doit dépasser le silence mesuré — et le ralenti
// doit se déclencher AVANT d'être passé sous ce seuil de sécurité.
assert.ok(t.targetAhead > PROVIDER_OUTAGE_S,
`cible ${t.targetAhead}s < silence mesuré ${PROVIDER_OUTAGE_S}s`);
assert.ok(t.lowAhead > PROVIDER_OUTAGE_S);
assert.ok(c.liveBufferLatencyMinRemain > PROVIDER_OUTAGE_S);
});
test('régulateur : ralentit sous la réserve, accélère au-dessus, sans osciller', () => {
const listeners = {};
let ahead = 0;
const video = {
addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
removeEventListener() {},
canPlayType: () => '',
play: () => Promise.resolve(),
pause() {},
get buffered() { return { length: 1, start: () => 0, end: () => 100 + ahead }; },
seekable: { length: 0 },
currentTime: 100,
playbackRate: 1,
paused: false,
seeking: false,
};
const window = {
addEventListener() {}, removeEventListener() {},
console: { log() {} },
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
parent: { postMessage() {} },
};
vm.runInContext(source, vm.createContext({
window, location: window.location,
document: { addEventListener() {}, removeEventListener() {} },
URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout, Date,
}));
const t = window.XFPlayerLiveTuning;
const p = new window.XFPlayer({ video, url: '/api/live/1/turbo.ts', type: 'live' });
p.mpegts = {};
p._started = true;
ahead = 13; p._tuneRate(); // session réelle : 13 s
assert.equal(video.playbackRate, t.slowRate);
ahead = t.lowAhead + 2; p._tuneRate(); // remonte, pas encore la cible
assert.equal(video.playbackRate, t.slowRate, 'repasse à 1 trop tôt : oscille');
ahead = t.targetAhead; p._tuneRate();
assert.equal(video.playbackRate, 1);
ahead = t.highAhead + 5; p._tuneRate(); // rafale de rattrapage
assert.equal(video.playbackRate, t.fastRate);
ahead = t.highAhead - 2; p._tuneRate();
assert.equal(video.playbackRate, t.fastRate, 'freine trop tôt : oscille');
ahead = t.targetAhead + 2; p._tuneRate();
assert.equal(video.playbackRate, 1);
});
test('live : la rafale de rattrapage après un silence ne fait pas sauter le programme', () => {
// Mesuré en prod : après un silence, le panneau renvoie d'un coup tout le
// retard ; l'avance a dépassé 45 s et le lecteur a sauté 2 fois de 20 s
// (programme perdu). Le retard doit se résorber en douceur (liveSync).
const observedPeakAhead = 45;
const c = liveMpegtsConfig();
assert.ok(c.liveBufferLatencyMaxLatency >= 2 * observedPeakAhead - 10,
`saut dès ${c.liveBufferLatencyMaxLatency}s d'avance : la rafale de rattrapage le déclenche`);
assert.ok(liveTuning().highAhead < observedPeakAhead,
'le régulateur doit déjà accélérer avant ce niveau');
});
test('live : la vidéo déjà vue est purgée plus tôt, l\'avance étant plus grande', () => {
const c = liveMpegtsConfig();
assert.ok(c.autoCleanupMaxBackwardDuration <= 30);
});
test('reprise après coupure : attendre une vraie réserve avant de relire', () => {
const calls = [];
const listeners = {};
let ahead = 0;
const video = {
addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
removeEventListener() {},
canPlayType: () => '',
play: () => { calls.push('play'); return Promise.resolve(); },
pause: () => { calls.push('pause'); },
get buffered() { return { length: 1, start: () => 0, end: () => 100 + ahead }; },
seekable: { length: 0 },
currentTime: 100,
playbackRate: 1,
seeking: false,
};
const fire = (type) => (listeners[type] || []).forEach((fn) => fn());
const window = {
addEventListener() {}, removeEventListener() {},
console: { log() {} },
location: { origin: 'https://xf.test', href: 'https://xf.test/', search: '' },
parent: { postMessage() {} },
};
const context = vm.createContext({
window, location: window.location,
document: { addEventListener() {}, removeEventListener() {} },
URLSearchParams, setInterval: () => 0, clearInterval() {}, setTimeout, Date,
});
vm.runInContext(source, context);
const player = new window.XFPlayer({ video, url: '/api/live/1/turbo.ts', type: 'live' });
player.mpegts = {}; // flux MPEG-TS en cours
player._wireVideoEvents();
fire('playing');
fire('waiting'); // le panneau se tait
assert.deepEqual(calls.slice(-1), ['pause'], 'le lecteur doit se mettre en attente');
ahead = 1; // 1 s arrive : trop peu, ne pas repartir
player._checkHold();
assert.ok(!calls.slice(1).includes('play'), 'reparti avec 1 s : recoupera aussitôt');
ahead = 5; // vraie réserve reconstituée
player._checkHold();
assert.equal(calls.at(-1), 'play');
});
test('live : le buffer déjà lu est purgé (sinon QuotaExceeded en séance longue)', () => {
const c = liveMpegtsConfig();
assert.equal(c.autoCleanupSourceBuffer, true);
+6
View File
@@ -141,6 +141,12 @@
// Contrat historique du lite : mise en pause manuelle → overlay lecture.
video.addEventListener('pause', function () {
// Pause technique : le lecteur attend que la réserve se
// reconstitue après une coupure, il repartira seul.
if (player && player._holding) {
showLoading('Reconstitution de la réserve');
return;
}
if (video.currentTime === 0 || video.paused) {
loading.style.display = 'none';
playOverlay.style.display = 'flex';
+121 -10
View File
@@ -58,6 +58,32 @@
// Erreurs MPEG-TS tolérées sur 60 s avant de basculer sur HLS.
var MPEGTS_MAX_ERRORS = 3;
// Après une coupure en direct, réserve à reconstituer avant de relire.
// Repartir dès la première seconde reçue transformait un silence du
// panneau en cascade de micro-coupures (6,3 s puis 1,7 s puis 2,0 s,
// mesuré) ; comme mpv (cache-pause), on attend une vraie réserve.
var REBUFFER_SECONDS = 4;
// Au-delà, on relance quand même avec ce qu'on a.
var REBUFFER_MAX_WAIT_MS = 15000;
// Régulateur de vitesse du direct MPEG-TS (voir _tuneRate).
//
// La réserve ne venait que de l'avance envoyée par le panneau au
// démarrage : 28 s une fois, 18 s une autre — et avec 13-20 s, un silence
// du panneau a fini en coupure de 5,5 s (télémétrie). Sous lowAhead, on
// lit à x0,97 (hauteur du son préservée) jusqu'à reconstituer targetAhead ;
// au-delà de highAhead (rafale de rattrapage du panneau), x1,1 jusqu'à
// settleHigh. Les seuils d'entrée et de sortie diffèrent : pas
// d'oscillation de vitesse.
var LIVE_TUNING = {
lowAhead: 20,
targetAhead: 25,
settleHigh: 28,
highAhead: 35,
slowRate: 0.97,
fastRate: 1.1
};
// ------------------------------------------------------------------
// Chargement paresseux des libs (aucune requête inutile)
// ------------------------------------------------------------------
@@ -497,20 +523,33 @@
// coupure toutes les 3-4 s et contenu sauté (12 sauts et 14 coupures
// en 30 s sur une chaîne FHD). La marge de 12 s couvre deux silences ;
// le saut ne sert plus qu'à borner un retard réellement accumulé.
//
// 25 s et non plus 12 : la télémétrie a mesuré un silence du panneau
// de ~15-20 s sur une connexion restée ouverte, alors que le lecteur
// n'avait gardé que 11-16 s — il venait de « consommer » à x1,1
// l'avance que le panneau envoie au démarrage. On la garde : ~25 s
// de retard sur le direct, en échange d'une lecture qui traverse ces
// silences sans figer.
//
// Saut seulement au-delà de 90 s : après un silence, le panneau
// renvoie d'un coup tout le retard. Avec un seuil à 45 s, l'avance
// le dépassait et le lecteur sautait 20 s de programme (2 fois en
// 1 min, mesuré). Le régulateur de vitesse (_tuneRate) résorbe
// désormais cet excédent en douceur.
liveBufferLatencyChasing: isLive,
liveBufferLatencyMaxLatency: 30,
liveBufferLatencyMinRemain: 12,
// Au-delà de 20 s de retard, accélérer légèrement (x1,1) jusqu'à
// revenir à 12 s : rattrapage invisible, sans saut ni coupure.
liveSync: isLive,
liveSyncMaxLatency: 20,
liveSyncTargetLatency: 12,
liveSyncPlaybackRate: 1.1,
liveBufferLatencyMaxLatency: 90,
liveBufferLatencyMinRemain: 25,
// liveSync de mpegts.js désactivé : il ne sait qu'accélérer et remet
// la vitesse à 1 sous sa cible, ce qui annulerait le ralenti de
// _tuneRate qui reconstitue la réserve.
liveSync: false,
// Purge du buffer déjà lu : sans elle, une longue séance finit par
// saturer le SourceBuffer (QuotaExceededError → erreur MPEG-TS).
// Avance jusqu'à 90 s : la vidéo déjà vue est purgée plus tôt pour
// rester sous le quota du SourceBuffer (~150 Mo en vidéo sur Chrome).
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 60,
autoCleanupMinBackwardDuration: 30,
autoCleanupMaxBackwardDuration: 30,
autoCleanupMinBackwardDuration: 15,
lazyLoad: false
}
);
@@ -678,6 +717,62 @@
// recréation du lecteur (déclenchée par le handler d'erreur).
};
/**
* Direct MPEG-TS à sec : se mettre en pause jusqu'à [REBUFFER_SECONDS] de
* réserve (voir la constante). HLS gère déjà sa propre reprise.
*/
XFPlayer.prototype._holdUntilBuffered = function () {
if (this._holding || !this.mpegts || this.type !== 'live') return;
var self = this;
this._holding = true;
this._holdSince = Date.now();
try { this.video.pause(); } catch (e) {}
this._holdTimer = setInterval(function () { self._checkHold(); }, 250);
};
/**
* Ajuste la vitesse du direct MPEG-TS selon l'avance (voir LIVE_TUNING).
* Appelé chaque seconde ; sans effet en pause, en attente de réserve,
* hors direct ou en HLS.
*/
XFPlayer.prototype._tuneRate = function () {
var v = this.video;
if (!this.mpegts || this.type !== 'live' || !this._started ||
this._holding || v.paused) {
return;
}
var ahead = this._bufferAhead();
var t = LIVE_TUNING;
var mode = this._rateMode || 'normal';
if (mode === 'slow' && ahead >= t.targetAhead) mode = 'normal';
else if (mode === 'fast' && ahead <= t.settleHigh) mode = 'normal';
else if (mode === 'normal' && ahead < t.lowAhead) mode = 'slow';
else if (mode === 'normal' && ahead > t.highAhead) mode = 'fast';
// Un saut de rattrapage peut faire passer directement d'un extrême à
// l'autre.
if (mode === 'slow' && ahead > t.highAhead) mode = 'fast';
if (mode === 'fast' && ahead < t.lowAhead) mode = 'slow';
this._rateMode = mode;
var rate = mode === 'slow' ? t.slowRate : (mode === 'fast' ? t.fastRate : 1);
if (v.playbackRate !== rate) {
try { v.playbackRate = rate; } catch (e) {}
}
};
/** Relance la lecture dès que la réserve est reconstituée. */
XFPlayer.prototype._checkHold = function () {
if (!this._holding) return;
if (this._destroyed) { clearInterval(this._holdTimer); return; }
var waited = Date.now() - this._holdSince;
if (this._bufferAhead() < REBUFFER_SECONDS && waited < REBUFFER_MAX_WAIT_MS) {
return;
}
clearInterval(this._holdTimer);
this._holding = false;
this._attemptPlay();
};
XFPlayer.prototype._noteStall = function () {
if (!this._started) return; // les attentes d'amorçage ne comptent pas
var now = Date.now();
@@ -723,6 +818,9 @@
});
this._on(v, 'pause', function () {
// Pause posée par l'attente de réserve : pas une pause utilisateur,
// l'interface ne doit pas basculer sur « lecture ».
if (self._holding) return;
self.send({ type: 'playback_status', status: 'paused' });
});
@@ -731,6 +829,7 @@
self._stallSince = Date.now();
}
self._noteStall();
if (self._started) self._holdUntilBuffered();
});
this._on(v, 'stalled', function () { self._noteStall(); });
@@ -744,6 +843,9 @@
self._report('seek', { from: self._lastPos, to: v.currentTime });
});
// Régulateur de vitesse du direct (voir LIVE_TUNING).
this._rateTimer = setInterval(function () { self._tuneRate(); }, 1000);
// Bilan toutes les 60 s : nombre et durée cumulée des coupures.
this._beatTimer = setInterval(function () {
if (!self._started) return;
@@ -897,6 +999,12 @@
self._attemptPlay();
break;
case 'pause':
// Pause voulue par l'utilisateur : l'attente de réserve ne doit
// pas relancer la lecture derrière son dos.
if (self._holding) {
clearInterval(self._holdTimer);
self._holding = false;
}
v.pause();
break;
case 'seek':
@@ -923,6 +1031,8 @@
this._destroyed = true;
clearInterval(this._posTimer);
clearInterval(this._beatTimer);
clearInterval(this._holdTimer);
clearInterval(this._rateTimer);
this._listeners.forEach(function (l) {
try { l.target.removeEventListener(l.type, l.handler); } catch (e) {}
});
@@ -939,4 +1049,5 @@
global.XFPlayer = XFPlayer;
global.XFPlayerProfiles = PROFILES;
global.XFPlayerLiveTuning = LIVE_TUNING;
})(window);