Fix : overlay showing some times
All checks were successful
release / build (push) Successful in 25s
release / verify-windows (push) Successful in 2m51s

This commit is contained in:
jeanotx32
2026-08-15 12:06:50 -04:00
parent 9a019447e7
commit cd3380d8d3
3 changed files with 233 additions and 53 deletions

View File

@@ -30,6 +30,8 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
- Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur). - Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur).
- [Pause automatique pendant les shows privés](#pause-automatique-pendant-les-shows-privés) - [Pause automatique pendant les shows privés](#pause-automatique-pendant-les-shows-privés)
(Stripchat), avec reprise et rappel du plein écran au retour du flux public. (Stripchat), avec reprise et rappel du plein écran au retour du flux public.
- Le curseur est [écarté du lecteur](#écarter-le-curseur-du-lecteur) à la fin de chaque
interaction, pour que la barre de contrôles ne finisse pas dans le fichier enregistré.
- Le plein écran est [mesuré, pas supposé](#mesurer-plutôt-que-demander), relu pendant la - Le plein écran est [mesuré, pas supposé](#mesurer-plutôt-que-demander), relu pendant la
capture et rétabli s'il se perd ; un capture et rétabli s'il se perd ; un
[lecteur figé](#lecteur-figé--rechargement-automatique) est détecté et la page rechargée. [lecteur figé](#lecteur-figé--rechargement-automatique) est détecté et la page rechargée.
@@ -448,16 +450,53 @@ la fenêtre semblait rétrécir juste après le réglage de qualité, comme un s
plein écran qui aurait annulé le premier — alors qu'il ne s'agissait que d'un Échap qui plein écran qui aurait annulé le premier — alors qu'il ne s'agissait que d'un Échap qui
sortait du plein écran natif que la touche du lecteur venait d'activer. sortait du plein écran natif que la touche du lecteur venait d'activer.
**Le curseur est écarté du lecteur une fois la qualité réglée**, sans quoi la barre de Voir aussi [Écarter le curseur du lecteur](#écarter-le-curseur-du-lecteur), qui referme la
contrôles du lecteur — qui se garde affichée tant qu'une souris réelle la survole — resterait séquence.
visible en permanence, faute de mouvement ultérieur. Le point de sortie est **calculé, pas
deviné** : une marge est cherchée autour de la position réelle de l'élément vidéo (en #### Écarter le curseur du lecteur
dessous en priorité, sinon au-dessus, à droite ou à gauche), confirmée par
`elementFromPoint` plutôt que supposée à un endroit fixe de la page. Si le lecteur couvre **Le curseur est écarté à la fin de chaque interaction avec la page** : rappel de plein écran,
tout le viewport sans la moindre marge, un évènement `mouseleave` non fiable est émis en réglage de qualité, et jusqu'aux abandons de ce réglage. Un lecteur vidéo garde sa barre de
dernier recours sur l'élément vidéo et ses parents proches — sans garantie si le site vérifie contrôles affichée tant qu'une souris réelle la survole — comportement voulu pour un vrai
`isTrusted` dessus comme il le fait sur le clic du bouton de qualité, mais sans risque non spectateur, mais qui la fige à l'écran ici, puisque plus rien ne bouge ensuite. Et cette barre
plus à tenter. est enregistrée avec le flux : c'est l'incrustation que l'on voit en travers de certains
replays.
Le cas qui échappait : une capture qui **démarre** passe par le réglage de qualité, qui se
terminait déjà par cet écart — mais une **reprise** (retour du flux public après un show privé
ou un passage hors-ligne) ne fait qu'envoyer la touche du lecteur, sans repasser par la
qualité. Le curseur restait donc exactement là où la dernière interaction l'avait laissé,
c'est-à-dire sur un bouton du lecteur. D'où une incrustation présente sur certains replays et
pas sur d'autres, sans qu'aucun réglage ne les distingue.
Trois recours, du plus fiable au moins :
1. **Hors du lecteur.** Le point est **calculé, pas deviné** : une marge est cherchée autour de
la position réelle de l'élément vidéo (en dessous en priorité, sinon au-dessus, à droite ou
à gauche), confirmée par `elementFromPoint` plutôt que supposée à un endroit fixe de la
page. Une version antérieure visait un coin fixe, en pariant qu'une barre du site y restait
affichée — un coin qui s'avère faire *partie* du lecteur produit exactement l'inverse de
l'effet recherché.
2. **Dans le lecteur, à l'écart de ses commandes.** Le cas du plein écran, justement : le
lecteur couvre tout, il n'existe aucun « dehors ». Le balayage part du haut de l'image (la
barre de contrôles occupe le bas chez tous les lecteurs) et **préfère la surface vidéo nue**,
seul endroit dont on sache qu'il n'épingle rien ; un habillage sans nom parlant (voile
cliquable, dégradé) fait un repli acceptable, mais n'est jamais préféré à la vidéo quand
celle-ci est atteignable. Tout ce qui ressemble à une commande — `button`, `[role=button]`,
lien, champ, ou une classe contenant `control`/`menu`/`btn`/`bar` — est écarté. Un point
posé sur l'image relance la minuterie d'auto-masquage du lecteur, qui fait alors disparaître
la barre tout seul : ce qui n'arrive jamais tant que le curseur reste sur un bouton.
3. **Évènements de sortie simulés**, en dernier recours, sur l'élément vidéo et ses parents
proches — sans garantie si le site vérifie `isTrusted` dessus comme il le fait sur le clic du
bouton de qualité, mais sans risque non plus à tenter.
Le résultat part au journal (`curseur écarté hors du lecteur`, `lecteur plein cadre : curseur
posé à l'écart des commandes`…) : c'est ce qui permet, devant un replay incrusté, de savoir si
le curseur avait été déplacé et où il avait atterri.
L'écart a lieu **après** la mesure du plein écran, jamais avant : déplacer le curseur réaffiche
les contrôles le temps de la minuterie, et le relevé porterait alors sur une page en train de
changer.
#### Lancement simple #### Lancement simple

View File

@@ -79,6 +79,50 @@ export function describeMetrics(
return parts.join(', '); return parts.join(', ');
} }
/**
* Ce qu'a pu faire {@link FirefoxDriver.parkPointer} du curseur.
*
* Relevé plutôt que supposé : c'est la seule façon de savoir, après coup et
* depuis le journal, pourquoi une incrustation est restée à l'écran sur un
* enregistrement — le curseur posé sur un bouton, ou pas déplacé du tout.
*/
/** Curseur effectivement déplacé, avec le point retenu. */
export interface ParkPoint {
where: 'outside' | 'inside';
x: number;
y: number;
over: string;
}
export type ParkOutcome =
/**
* Curseur posé quelque part : `outside`, hors du lecteur — le cas idéal, le
* survol cesse aussitôt ; `inside`, dans le lecteur mais à l'écart de ses
* commandes (le cas du plein écran, où il n'existe aucun dehors), la minuterie
* d'auto-masquage du lecteur faisant le reste. `over` nomme l'élément
* réellement sous le curseur à l'arrivée.
*/
| ParkPoint
/**
* Aucun point utilisable : sortie de survol simulée en dernier ressort
* (`events`), ou rien à faire faute de lecteur dans la page (`none`).
*/
| { where: 'events' | 'none' };
/** Résume un éloignement de curseur pour le journal. */
export function describePark(outcome: ParkOutcome): string {
switch (outcome.where) {
case 'outside':
return `curseur écarté hors du lecteur (sur ${outcome.over})`;
case 'inside':
return `lecteur plein cadre : curseur posé à l'écart des commandes (sur ${outcome.over})`;
case 'events':
return 'aucun point hors des commandes : sortie de survol simulée';
default:
return 'curseur non déplacé : aucun lecteur repéré dans la page';
}
}
/** /**
* Instantané de la lecture, tel que le lecteur le rapporte. * Instantané de la lecture, tel que le lecteur le rapporte.
* *
@@ -274,6 +318,20 @@ export class FirefoxController {
} }
this.fullscreen = state; this.fullscreen = state;
// Après la mesure, jamais avant : déplacer le curseur réaffiche les
// contrôles du lecteur le temps de sa minuterie, et le relevé porterait
// alors sur une page en train de changer.
//
// C'est ici que se jouait l'incrustation restée en travers de certaines
// reprises. Une capture qui démarre passe par la sélection de qualité, qui
// se terminait déjà par cet écart ; un simple rappel de plein écran — le
// chemin de toute reprise après un show privé ou un passage hors-ligne —
// n'y passait pas, et laissait le curseur exactement là où la dernière
// interaction l'avait posé.
const parked = await this.parkPointer();
this.log('info', describePark(parked));
return { return {
method: attempts > 1 ? 'webdriver (touche, 2 essais)' : 'webdriver (touche)', method: attempts > 1 ? 'webdriver (touche, 2 essais)' : 'webdriver (touche)',
target: this.url ?? undefined, target: this.url ?? undefined,
@@ -351,8 +409,15 @@ export class FirefoxController {
await delay(150); await delay(150);
} }
if (!tooltip?.found) return { ok: false, reason: 'menu de qualité non ouvert' }; // Un abandon laisse le curseur sur le bouton de qualité, donc les contrôles
// épinglés à l'écran : la qualité n'aura pas été réglée, mais au moins
// l'incrustation ne sera pas enregistrée par-dessus le flux.
if (!tooltip?.found) {
await this.parkPointer();
return { ok: false, reason: 'menu de qualité non ouvert' };
}
if (tooltip.options.length === 0) { if (tooltip.options.length === 0) {
await this.parkPointer();
return { return {
ok: false, ok: false,
reason: 'menu ouvert mais aucune option de résolution trouvée', reason: 'menu ouvert mais aucune option de résolution trouvée',
@@ -388,12 +453,10 @@ export class FirefoxController {
await this.click(buttonPoint.x, buttonPoint.y); await this.click(buttonPoint.x, buttonPoint.y);
} }
// Le curseur reste sinon posé sur le bouton ou l'option cliquée : la barre // Le curseur reste sinon posé sur le bouton ou l'option cliquée, ce qui
// de contrôles du lecteur, elle, se garde visible tant qu'une souris réelle // maintient la barre de contrôles affichée (voir {@link parkPointer}).
// la survole — un comportement voulu pour ne pas la faire disparaître sous const parked = await this.parkPointer();
// le curseur d'un vrai spectateur, mais qui la laisse affichée en this.log('info', describePark(parked));
// permanence ici puisque rien ne bouge plus ensuite.
await this.moveAway();
await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined); await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined);
@@ -895,20 +958,34 @@ export class FirefoxController {
} }
/** /**
* Déplace le curseur vers un point réellement hors du lecteur, sans cliquer. * Écarte le curseur de tout ce qui maintient les contrôles du lecteur
* affichés, sans cliquer. À appeler à la fin de chaque interaction.
* *
* Calculé plutôt que deviné. Une version antérieure visait un coin fixe * Un lecteur vidéo garde sa barre de contrôles visible tant qu'une souris la
* (haut gauche de la page), en supposant qu'une barre du site y restait * survole — comportement voulu pour un vrai spectateur, mais qui la fige à
* toujours affichée au-dessus du lecteur. Rien ne garantissait que ça * l'écran ici, puisque plus rien ne bouge ensuite. Et cette barre est
* tienne — et un coin qui s'avère plutôt FAIRE PARTIE du lecteur produit * enregistrée avec le flux : c'est l'incrustation que l'on voit en travers de
* l'inverse de l'effet recherché : la souris y reste posée, les contrôles * certains replays.
* restent affichés. Ici, la marge est cherchée autour de la position réelle *
* du lecteur, confirmée par `elementFromPoint` plutôt que supposée. * Trois recours, du plus fiable au moins :
*
* 1. **Hors du lecteur.** Calculé plutôt que deviné : une version antérieure
* visait un coin fixe de la page, en supposant qu'une barre du site y
* restait affichée. Un coin qui s'avère FAIRE PARTIE du lecteur produit
* exactement l'inverse de l'effet recherché.
* 2. **Dans le lecteur, à l'écart des contrôles.** Le cas du plein écran :
* le lecteur couvre tout, il n'existe aucun « dehors ». Un point posé sur
* la surface vidéo nue relance quand même la minuterie d'auto-masquage du
* lecteur, qui fait alors disparaître la barre toute seule — ce qui
* n'arrive jamais tant que le curseur reste sur un bouton.
* 3. **Évènements de sortie simulés**, en dernier ressort. Un site qui
* vérifie `isTrusted` les ignorera, mais ils ne coûtent rien à tenter.
*/ */
private async moveAway(): Promise<void> { async parkPointer(): Promise<ParkOutcome> {
const point = await this.safePointOutsidePlayer(); const target = await this.pointerParkTarget();
if (point) {
await this.send( if (target) {
const moved = await this.send(
'input.performActions', 'input.performActions',
{ {
context: this.context, context: this.context,
@@ -918,26 +995,24 @@ export class FirefoxController {
id: 'stream-control-pointer', id: 'stream-control-pointer',
parameters: { pointerType: 'mouse' }, parameters: { pointerType: 'mouse' },
actions: [ actions: [
{ type: 'pointerMove', x: Math.round(point.x), y: Math.round(point.y), origin: 'viewport' }, { type: 'pointerMove', x: Math.round(target.x), y: Math.round(target.y), origin: 'viewport' },
], ],
}, },
], ],
}, },
5000, 5000,
).catch(() => undefined); ).then(
return; () => true,
() => false,
);
if (moved) return target;
} }
// Aucune marge trouvée : le lecteur couvre tout le viewport, sans point const dispatched = await this.evaluate(`(() => {
// où poser le curseur en dehors de lui. Un évènement non fiable ne fera
// peut-être rien si le site vérifie `isTrusted` sur celui-ci comme il le
// fait sur le clic du bouton de qualité, mais ne coûte rien à tenter —
// mieux vaut essayer que renoncer complètement à masquer les contrôles.
await this.evaluate(`(() => {
const start = document.querySelector('video') const start = document.querySelector('video')
|| document.querySelector('.player-resolution')?.closest('[class*="player"]') || document.querySelector('.player-resolution')?.closest('[class*="player"]')
|| document.querySelector('.player-resolution'); || document.querySelector('.player-resolution');
if (!start) return; if (!start) return false;
// L'écouteur qui cache les contrôles vit le plus souvent sur le // L'écouteur qui cache les contrôles vit le plus souvent sur le
// conteneur du lecteur (survol de la zone entière), pas sur la vidéo // conteneur du lecteur (survol de la zone entière), pas sur la vidéo
// elle-même — inconnu d'ici, donc émis sur quelques niveaux de parents // elle-même — inconnu d'ici, donc émis sur quelques niveaux de parents
@@ -948,20 +1023,21 @@ export class FirefoxController {
node.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); node.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
node = node.parentElement; node = node.parentElement;
} }
})()`).catch(() => undefined); return true;
})()`).catch(() => false);
return { where: dispatched === true ? 'events' : 'none' };
} }
/** /**
* Point de viewport garanti hors du lecteur, ou `null` si aucun n'a pu être * Point de viewport où poser le curseur, ou `null` si la page n'offre rien.
* trouvé (lecteur couvrant tout l'écran, sans marge disponible dans aucune
* direction).
* *
* L'ancre est l'élément vidéo lui-même — le repère le plus universel d'un * L'ancre est l'élément vidéo lui-même — le repère le plus universel d'un
* lecteur, quelle que soit la façon dont le site habille ses contrôles * lecteur, quelle que soit la façon dont le site habille ses contrôles
* autour. À défaut (site sans balise `<video>` classique), on retombe sur * autour. À défaut (site sans balise `<video>` classique), on retombe sur
* le conteneur du bouton de qualité déjà repéré plus haut. * le conteneur du bouton de qualité déjà repéré plus haut.
*/ */
private async safePointOutsidePlayer(): Promise<{ x: number; y: number } | null> { private async pointerParkTarget(): Promise<ParkPoint | null> {
const raw = await this.evaluate(`JSON.stringify((() => { const raw = await this.evaluate(`JSON.stringify((() => {
const anchor = document.querySelector('video') const anchor = document.querySelector('video')
|| document.querySelector('.player-resolution')?.closest('[class*="player"]') || document.querySelector('.player-resolution')?.closest('[class*="player"]')
@@ -972,29 +1048,67 @@ export class FirefoxController {
const vw = window.innerWidth; const vw = window.innerWidth;
const vh = window.innerHeight; const vh = window.innerHeight;
const margin = 20; const margin = 20;
const inside = (p) => p.x >= 0 && p.x <= vw && p.y >= 0 && p.y <= vh;
const name = (el) => {
if (!el) return '?';
const cls = typeof el.className === 'string' ? el.className.trim().split(/\\s+/)[0] : '';
return el.tagName.toLowerCase() + (cls ? '.' + cls : '');
};
// Dans cet ordre : en dessous, au-dessus, à droite, à gauche — sous le // 1. Hors du lecteur. Dans cet ordre : en dessous, au-dessus, à droite, à
// lecteur est le plus souvent une zone de page ordinaire (chat, // gauche — sous le lecteur est le plus souvent une zone de page ordinaire
// commentaires), donc le candidat le plus sûr en premier. // (chat, commentaires), donc le candidat le plus sûr en premier.
const candidates = [ const outside = [
{ x: rect.left + rect.width / 2, y: rect.bottom + margin }, { x: rect.left + rect.width / 2, y: rect.bottom + margin },
{ x: rect.left + rect.width / 2, y: rect.top - margin }, { x: rect.left + rect.width / 2, y: rect.top - margin },
{ x: rect.right + margin, y: rect.top + rect.height / 2 }, { x: rect.right + margin, y: rect.top + rect.height / 2 },
{ x: rect.left - margin, y: rect.top + rect.height / 2 }, { x: rect.left - margin, y: rect.top + rect.height / 2 },
]; ];
for (const point of outside) {
for (const point of candidates) { if (!inside(point)) continue;
if (point.x < 0 || point.x > vw || point.y < 0 || point.y > vh) continue;
// Confirmé par l'élément réellement affiché à ce point, pas seulement // Confirmé par l'élément réellement affiché à ce point, pas seulement
// par la géométrie : un habillage du site peut déborder du rectangle // par la géométrie : un habillage du site peut déborder du rectangle
// de l'ancre sans en faire partie au sens du DOM. // de l'ancre sans en faire partie au sens du DOM.
const el = document.elementFromPoint(point.x, point.y); const el = document.elementFromPoint(point.x, point.y);
if (el && el !== anchor && !anchor.contains(el)) return point; if (el && el !== anchor && !anchor.contains(el)) {
return { where: 'outside', x: point.x, y: point.y, over: name(el) };
}
}
// 2. Dans le lecteur, à l'écart de ses commandes. Balayage par bandes du
// haut vers le bas : la barre de contrôles occupe le bas chez tous les
// lecteurs, et le sommet de l'image est ce qui a le moins de chances de
// porter un bouton.
const CONTROLS = 'button,[role="button"],a,input,select,textarea,'
+ '[class*="control"],[class*="tooltip"],[class*="menu"],[class*="btn"],[class*="bar"]';
const points = [];
for (const fy of [0.18, 0.3, 0.1, 0.42]) {
for (const fx of [0.5, 0.25, 0.75]) {
const point = { x: rect.left + rect.width * fx, y: rect.top + rect.height * fy };
if (inside(point)) points.push(point);
}
}
// Deux passes, et pas une seule avec exclusion : la surface vidéo nue est
// le seul endroit dont on sache avec certitude qu'il n'épingle rien. Un
// habillage sans nom parlant (un voile cliquable, un dégradé) fait un
// repli acceptable — les lecteurs masquent leurs contrôles après
// inactivité même curseur posé sur l'image — mais il ne doit pas être
// préféré à la vidéo elle-même quand celle-ci est atteignable.
for (const point of points) {
if (document.elementFromPoint(point.x, point.y) === anchor) {
return { where: 'inside', x: point.x, y: point.y, over: name(anchor) };
}
}
for (const point of points) {
const el = document.elementFromPoint(point.x, point.y);
if (!el || el.closest(CONTROLS)) continue;
return { where: 'inside', x: point.x, y: point.y, over: name(el) };
} }
return null; return null;
})())`).catch(() => null); })())`).catch(() => null);
if (typeof raw !== 'string') return null; if (typeof raw !== 'string') return null;
return JSON.parse(raw) as { x: number; y: number } | null; return JSON.parse(raw) as ParkPoint | null;
} }
private async evaluate(expression: string, userActivation = false): Promise<unknown> { private async evaluate(expression: string, userActivation = false): Promise<unknown> {

View File

@@ -36,6 +36,7 @@ import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts';
import { import {
FirefoxController, FirefoxController,
describeMetrics, describeMetrics,
describePark,
type PlaybackSample, type PlaybackSample,
type StreamQualityOutcome, type StreamQualityOutcome,
} from './firefox.ts'; } from './firefox.ts';
@@ -324,9 +325,35 @@ async function preparePlayer(waitMs: number): Promise<unknown> {
// l'éloignement du curseur reste la dernière chose à toucher la page avant // l'éloignement du curseur reste la dernière chose à toucher la page avant
// l'enregistrement. // l'enregistrement.
await selectMaxStreamQuality(); await selectMaxStreamQuality();
// Les deux étapes ci-dessus se terminent chacune en écartant le curseur du
// lecteur. Toutes deux désactivées, plus rien ne le fait : la capture
// démarrerait avec les contrôles éventuellement figés à l'écran, hérités du
// dernier geste — la souris n'a aucune raison d'avoir bougé depuis.
if (!fullscreen.enabled && !watcher.snapshotSettings.streamQuality.enabled) {
await parkPointer();
}
return fullscreenResult; return fullscreenResult;
} }
/**
* Écarte le curseur de la barre de contrôles du lecteur.
*
* Sans effet hors mode BiDi : déplacer une souris dans une page suppose de
* parler au navigateur. Un échec n'est jamais bloquant — au pire, une
* incrustation reste visible sur l'enregistrement.
*/
async function parkPointer(): Promise<void> {
const bidi = driver();
if (!bidi) return;
const outcome = await bidi.parkPointer().catch((err: Error) => {
report('warn', `Curseur non écarté du lecteur : ${err.message}`);
return null;
});
if (outcome) report('info', describePark(outcome));
}
/** Ouvre une page par la voie correspondant au mode de pilotage. */ /** Ouvre une page par la voie correspondant au mode de pilotage. */
async function openPage(url: string): Promise<unknown> { async function openPage(url: string): Promise<unknown> {
const bidi = driver(); const bidi = driver();