From 043823600b9a12a106e7dd304c7bc9c713812dec Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Wed, 12 Aug 2026 14:19:02 +0200 Subject: [PATCH] Fix : quality selection --- README.md | 17 +-- packages/agent/src/firefox.ts | 228 +++++++++++++++++++++++++--------- packages/agent/src/index.ts | 5 +- 3 files changed, 183 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 115e4b4..9512853 100644 --- a/README.md +++ b/README.md @@ -297,13 +297,15 @@ en 720p — OBS ne peut pas inventer des pixels absents du flux source. Stripchat ne démarre pas toujours sur sa meilleure qualité disponible, et chaque nouvelle page (donc chaque nouveau stream ouvert) repart de ce choix du site, pas du précédent réglage manuel. En mode BiDi, l'agent clique le bouton de qualité du lecteur puis la meilleure option -du menu, en un seul aller-retour : +du menu : -```js -document.querySelector('.player-resolution').click(); -// attend le menu, puis clique l'option marquée « (Source) », ou à défaut celle dont la -// valeur numérique (extraite de « 1080p », « 960p »…) est la plus élevée -``` +1. repère le bouton (`.player-resolution`) et clique dessus, +2. attend le menu, puis clique l'option marquée « (Source) », ou à défaut celle dont la + valeur numérique (extraite de « 1080p », « 960p »…) est la plus élevée. + +Le clic part par un vrai évènement souris WebDriver (`input.performActions`), pas par un +`.click()` scripté dans la page : un essai au `.click()` DOM laissait le menu fermé — le site +ignore apparemment les clics non fiables (`isTrusted: false`) sur ce bouton. La comparaison porte sur la valeur numérique, pas sur la position dans la liste : une échelle tronquée (rien au-dessus de 480p) ou une valeur atypique — souvent le cas des flux @@ -313,7 +315,8 @@ trompent donc pas le choix. Couplé à la structure actuelle du lecteur Stripchat — deux classes CSS (`.player-resolution`, `.player-resolution-tooltip__button--resolution`) relevées en inspectant la page en direct. Sans effet, mais sans erreur non plus (juste un avertissement -dans l'historique), si Stripchat change ce balisage. +dans l'historique), si Stripchat change ce balisage — l'avertissement inclut alors un extrait +du menu tel qu'ouvert, pour diagnostiquer sans repasser par les DevTools. #### Lancement simple diff --git a/packages/agent/src/firefox.ts b/packages/agent/src/firefox.ts index 439634f..e6a0ead 100644 --- a/packages/agent/src/firefox.ts +++ b/packages/agent/src/firefox.ts @@ -23,6 +23,23 @@ export interface StreamQualityOutcome { already?: boolean; /** Raison de l'échec, présente seulement si `!ok`. */ reason?: string; + /** Extrait de diagnostic (menu trouvé mais vide, p. ex.) — pour le journal, pas pour l'utilisateur. */ + debug?: string; +} + +/** Une option de résolution repérée dans le menu, avec ses coordonnées écran. */ +interface TooltipOption { + label: string; + x: number; + y: number; + active: boolean; +} + +/** État du menu de qualité au moment de la lecture. */ +interface TooltipState { + found: boolean; + options: TooltipOption[]; + debug?: string; } /** @@ -173,11 +190,16 @@ export class FirefoxController { * * Stripchat ne démarre pas toujours sur son maximum : la valeur active au * chargement varie, et chaque nouvelle page (donc chaque nouveau stream - * ouvert) repart du choix du site, pas du précédent. Le tout se joue en un - * seul aller-retour BiDi — un script auto-suffisant qui ouvre le menu, - * attend son montage, et clique la meilleure option — plutôt qu'une suite - * d'appels synchronisés par des délais fixes, plus fragile face à la - * variabilité du temps de rendu. + * ouvert) repart du choix du site, pas du précédent. + * + * Le clic part par `input.performActions` (un vrai évènement souris, comme + * la touche plein écran plus haut), pas par un `.click()` scripté dans la + * page : un premier essai au `.click()` DOM laissait le menu fermé — le + * bouton existait bien, mais l'ouverture ne se déclenchait pas depuis un + * évènement non fiable (`isTrusted: false`). D'où l'aller-retour en + * plusieurs étapes BiDi (repérer le bouton, cliquer réellement, attendre le + * menu, repérer puis cliquer la meilleure option) plutôt qu'un unique + * script auto-suffisant. * * Couplé à la structure actuelle du lecteur (`.player-resolution`, * `.player-resolution-tooltip__button--resolution`) : sans effet, mais sans @@ -187,63 +209,61 @@ export class FirefoxController { async selectMaxStreamQuality(): Promise { await this.ensureReady(); - const expression = `(async () => { - const button = document.querySelector('.player-resolution'); - if (!button) return JSON.stringify({ ok: false, reason: 'bouton qualité introuvable' }); - button.click(); - - const deadline = Date.now() + 3000; - let options = []; - while (Date.now() < deadline) { - options = [...document.querySelectorAll( - '.player-resolution-tooltip__resolutions .player-resolution-tooltip__button--resolution' - )]; - if (options.length > 0) break; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - if (options.length === 0) return JSON.stringify({ ok: false, reason: 'menu de qualité non ouvert' }); - - // « (Source) » est le signal le plus sûr — c'est le site qui désigne le - // flux non dégradé, quelle que soit sa valeur numérique. À défaut, on - // compare les hauteurs réelles plutôt que de supposer un ordre de liste - // décroissant : une échelle tronquée (rien au-dessus de 480p) ou une - // valeur atypique (souvent le cas des flux verticaux, pas calée sur - // l'échelle 240/480/720/1080) ne doit pas tromper le choix. - const height = (el) => { - // Antislash doublé à dessein : cette expression vit dans un template - // literal (le code de la page, pas du TypeScript exécuté ici). Un - // seul antislash y est une séquence d'échappement inconnue, que le - // moteur JS avale silencieusement — «/(\\d+)/» redevient «/(d+)/», - // qui ne trouve jamais rien, et le classement retombe alors sur - // l'ordre du DOM au lieu de la valeur réelle. - const match = /(\\d+)/.exec(el.getAttribute('data-resolution') || el.textContent); - return match ? parseInt(match[1], 10) : -1; - }; - const best = - options.find((el) => /source/i.test(el.textContent)) || - options.reduce((a, b) => (height(b) > height(a) ? b : a)); - const already = best.classList.contains('player-resolution-tooltip__button--active'); - const label = best.getAttribute('data-resolution') || best.textContent.trim(); - - if (!already) best.click(); - // Referme le menu comme le ferait un clic ailleurs sur la page. - document.body.click(); - - return JSON.stringify({ ok: true, label, already }); - })()`; - - // BiDi sérialise un objet renvoyé par la page sous forme de paires - // [clé, valeur], pas comme un objet JS ordinaire : la chaîne JSON évite - // d'avoir à décoder ce format, une chaîne restant une chaîne. - const raw = await this.evaluate(expression).catch((err: Error) => - JSON.stringify({ ok: false, reason: err.message }), + // Sans activation de l'onglet, le clic partirait vers une page en + // arrière-plan, dont le lecteur ignore les évènements souris. + await this.send('browsingContext.activate', { context: this.context }, 5000).catch(() => + undefined, ); - try { - return JSON.parse(typeof raw === 'string' ? raw : String(raw)) as StreamQualityOutcome; - } catch { - return { ok: false, reason: 'réponse illisible' }; + const buttonPoint = await this.locate("document.querySelector('.player-resolution')").catch( + () => null, + ); + + if (!buttonPoint) return { ok: false, reason: 'bouton qualité introuvable' }; + await this.click(buttonPoint.x, buttonPoint.y); + + // Le menu s'anime à l'ouverture — quelques essais espacés plutôt qu'une + // seule lecture immédiate. + const deadline = Date.now() + 3000; + let tooltip: TooltipState | null = null; + while (Date.now() < deadline) { + tooltip = await this.readTooltip().catch(() => null); + if (tooltip?.found && tooltip.options.length > 0) break; + await delay(150); } + + if (!tooltip?.found) return { ok: false, reason: 'menu de qualité non ouvert' }; + if (tooltip.options.length === 0) { + return { + ok: false, + reason: 'menu ouvert mais aucune option de résolution trouvée', + debug: tooltip.debug, + }; + } + + // « (Source) » est le signal le plus sûr — c'est le site qui désigne le + // flux non dégradé, quelle que soit sa valeur numérique. À défaut, on + // compare les hauteurs réelles plutôt que de supposer un ordre de liste + // décroissant : une échelle tronquée (rien au-dessus de 480p) ou une + // valeur atypique (souvent le cas des flux verticaux, pas calée sur + // l'échelle 240/480/720/1080) ne doit pas tromper le choix. + const height = (label: string) => { + const match = /(\d+)/.exec(label); + return match?.[1] ? parseInt(match[1], 10) : -1; + }; + const best = + tooltip.options.find((option) => /source/i.test(option.label)) || + tooltip.options.reduce((a, b) => (height(b.label) > height(a.label) ? b : a)); + + if (!best.active) { + await this.click(best.x, best.y); + } else { + // Referme le menu sans rien changer. + await this.pressEscape(); + } + await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined); + + return { ok: true, label: best.label, already: best.active }; } /** Décharge le lecteur sans détruire la fenêtre — donc sans casser la source OBS. */ @@ -548,6 +568,96 @@ export class FirefoxController { return result === true; } + /** + * Coordonnées écran du premier élément que `expression` désigne, ou `null` + * s'il est absent. `expression` est une IIFE renvoyant l'élément (pas ses + * coordonnées) — le calcul de rectangle est fait ici, une fois pour toutes. + */ + private async locate(expression: string): Promise<{ x: number; y: number } | null> { + const raw = await this.evaluate(`JSON.stringify((() => { + const el = (${expression}); + if (!el) return null; + el.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = el.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + })())`); + if (typeof raw !== 'string') return null; + return JSON.parse(raw) as { x: number; y: number } | null; + } + + /** État actuel du menu de qualité — ouvert ou non, options et leurs coordonnées. */ + private async readTooltip(): Promise { + // BiDi sérialise un objet renvoyé par la page sous forme de paires + // [clé, valeur], pas comme un objet JS ordinaire : la chaîne JSON évite + // d'avoir à décoder ce format, une chaîne restant une chaîne. + const raw = await this.evaluate(`JSON.stringify((() => { + const tooltip = document.querySelector('.player-resolution-tooltip'); + if (!tooltip) return { found: false, options: [] }; + const options = [...tooltip.querySelectorAll( + '.player-resolution-tooltip__button--resolution' + )].map((el) => { + const rect = el.getBoundingClientRect(); + return { + label: el.getAttribute('data-resolution') || el.textContent.trim(), + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + active: el.classList.contains('player-resolution-tooltip__button--active'), + }; + }); + // Le menu est ouvert (found) mais peut-être avec un autre balisage que + // celui attendu : ce fragment aide à le repérer sans nouvelle capture + // manuelle depuis les DevTools. + return { found: true, options, debug: options.length === 0 ? tooltip.outerHTML.slice(0, 800) : undefined }; + })())`); + if (typeof raw !== 'string') return { found: false, options: [] }; + return JSON.parse(raw) as TooltipState; + } + + /** Clic souris réel (pas un `.click()` scripté) à des coordonnées de viewport. */ + private async click(x: number, y: number): Promise { + await this.send( + 'input.performActions', + { + context: this.context, + actions: [ + { + type: 'pointer', + id: 'stream-control-pointer', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', x: Math.round(x), y: Math.round(y), origin: 'viewport' }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ], + }, + 5000, + ); + // Laisse la page réagir (ouverture animée du menu, p. ex.) avant la suite. + await delay(150); + } + + private async pressEscape(): Promise { + await this.send( + 'input.performActions', + { + context: this.context, + actions: [ + { + type: 'key', + id: 'stream-control-keyboard', + actions: [ + { type: 'keyDown', value: webdriverKey('escape') }, + { type: 'keyUp', value: webdriverKey('escape') }, + ], + }, + ], + }, + 5000, + ).catch(() => undefined); + } + private async evaluate(expression: string, userActivation = false): Promise { const response = await this.send<{ type: string; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3fb3261..b6ed88d 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -432,7 +432,10 @@ async function selectMaxStreamQuality(): Promise { .catch((err: Error) => ({ ok: false, reason: err.message })); if (!result.ok) { - report('warn', `Qualité du flux non réglée : ${result.reason}`, 'quality.failed'); + // Le diagnostic (menu ouvert mais balisage inattendu) part dans le même + // message : plus vite exploitable que de redemander une capture DevTools. + const suffix = result.debug ? ` — ${result.debug}` : ''; + report('warn', `Qualité du flux non réglée : ${result.reason}${suffix}`, 'quality.failed'); return; } report(