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).
- [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.
- 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
capture et rétabli s'il se perd ; un
[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
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
contrôles du lecteur — qui se garde affichée tant qu'une souris réelle la survole — resterait
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
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
tout le viewport sans la moindre marge, un évènement `mouseleave` non fiable est émis 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.
Voir aussi [Écarter le curseur du lecteur](#écarter-le-curseur-du-lecteur), qui referme la
séquence.
#### Écarter le curseur du lecteur
**Le curseur est écarté à la fin de chaque interaction avec la page** : rappel de plein écran,
réglage de qualité, et jusqu'aux abandons de ce réglage. Un lecteur vidéo garde sa barre de
contrôles affichée tant qu'une souris réelle la survole — comportement voulu pour un vrai
spectateur, mais qui la fige à l'écran ici, puisque plus rien ne bouge ensuite. Et cette barre
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

View File

@@ -79,6 +79,50 @@ export function describeMetrics(
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.
*
@@ -274,6 +318,20 @@ export class FirefoxController {
}
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 {
method: attempts > 1 ? 'webdriver (touche, 2 essais)' : 'webdriver (touche)',
target: this.url ?? undefined,
@@ -351,8 +409,15 @@ export class FirefoxController {
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) {
await this.parkPointer();
return {
ok: false,
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);
}
// Le curseur reste sinon posé sur le bouton ou l'option cliquée : la barre
// de contrôles du lecteur, elle, se garde visible tant qu'une souris réelle
// la survole — un comportement voulu pour ne pas la faire disparaître sous
// le curseur d'un vrai spectateur, mais qui la laisse affichée en
// permanence ici puisque rien ne bouge plus ensuite.
await this.moveAway();
// Le curseur reste sinon posé sur le bouton ou l'option cliquée, ce qui
// maintient la barre de contrôles affichée (voir {@link parkPointer}).
const parked = await this.parkPointer();
this.log('info', describePark(parked));
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
* (haut gauche de la page), en supposant qu'une barre du site y restait
* toujours affichée au-dessus du lecteur. Rien ne garantissait que ça
* tienne — et un coin qui s'avère plutôt FAIRE PARTIE du lecteur produit
* l'inverse de l'effet recherché : la souris y reste posée, les contrôles
* 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.
* Un lecteur vidéo garde sa barre de contrôles visible tant qu'une souris la
* survole — comportement voulu pour un vrai spectateur, mais qui la fige à
* l'écran ici, puisque plus rien ne bouge ensuite. Et cette barre est
* enregistrée avec le flux : c'est l'incrustation que l'on voit en travers de
* certains replays.
*
* 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> {
const point = await this.safePointOutsidePlayer();
if (point) {
await this.send(
async parkPointer(): Promise<ParkOutcome> {
const target = await this.pointerParkTarget();
if (target) {
const moved = await this.send(
'input.performActions',
{
context: this.context,
@@ -918,26 +995,24 @@ export class FirefoxController {
id: 'stream-control-pointer',
parameters: { pointerType: 'mouse' },
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,
).catch(() => undefined);
return;
).then(
() => true,
() => false,
);
if (moved) return target;
}
// Aucune marge trouvée : le lecteur couvre tout le viewport, sans point
// 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 dispatched = await this.evaluate(`(() => {
const start = document.querySelector('video')
|| document.querySelector('.player-resolution')?.closest('[class*="player"]')
|| document.querySelector('.player-resolution');
if (!start) return;
if (!start) return false;
// 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
// 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 = 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
* trouvé (lecteur couvrant tout l'écran, sans marge disponible dans aucune
* direction).
* Point de viewport où poser le curseur, ou `null` si la page n'offre rien.
*
* 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
* autour. À défaut (site sans balise `<video>` classique), on retombe sur
* 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 anchor = document.querySelector('video')
|| document.querySelector('.player-resolution')?.closest('[class*="player"]')
@@ -972,29 +1048,67 @@ export class FirefoxController {
const vw = window.innerWidth;
const vh = window.innerHeight;
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
// lecteur est le plus souvent une zone de page ordinaire (chat,
// commentaires), donc le candidat le plus sûr en premier.
const candidates = [
// 1. Hors du lecteur. Dans cet ordre : en dessous, au-dessus, à droite, à
// gauche — sous le lecteur est le plus souvent une zone de page ordinaire
// (chat, commentaires), donc le candidat le plus sûr en premier.
const outside = [
{ x: rect.left + rect.width / 2, y: rect.bottom + margin },
{ x: rect.left + rect.width / 2, y: rect.top - margin },
{ x: rect.right + margin, y: rect.top + rect.height / 2 },
{ x: rect.left - margin, y: rect.top + rect.height / 2 },
];
for (const point of candidates) {
if (point.x < 0 || point.x > vw || point.y < 0 || point.y > vh) continue;
for (const point of outside) {
if (!inside(point)) continue;
// 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
// de l'ancre sans en faire partie au sens du DOM.
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;
})())`).catch(() => 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> {

View File

@@ -36,6 +36,7 @@ import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts';
import {
FirefoxController,
describeMetrics,
describePark,
type PlaybackSample,
type StreamQualityOutcome,
} 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'enregistrement.
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;
}
/**
* É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. */
async function openPage(url: string): Promise<unknown> {
const bidi = driver();