Fix : overlay showing some times
This commit is contained in:
59
README.md
59
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user