fix bla
All checks were successful
release / build (push) Successful in 25s
release / verify-windows (push) Successful in 1m6s

This commit is contained in:
jeanotx32
2026-08-12 04:18:44 +02:00
parent 8fef1aacb0
commit ea8eeec129
4 changed files with 58 additions and 103 deletions

View File

@@ -181,15 +181,21 @@ Le renversement est là : **l'agent possède le navigateur** au lieu de lui envo
en espérant. Il parle à Firefox, pas au serveur d'affichage — d'où le fonctionnement en espérant. Il parle à Firefox, pas au serveur d'affichage — d'où le fonctionnement
identique sous Wayland, où xdotool ne voit rien. identique sous Wayland, où xdotool ne voit rien.
Séquence du rappel de plein écran, dans cet ordre volontaire : Séquence du rappel de plein écran :
1. `browsingContext.activate` — sans quoi la touche partirait vers un onglet d'arrière-plan ; 1. `browsingContext.activate` — sans quoi la touche partirait vers un onglet d'arrière-plan ;
2. `input.performActions` — la touche configurée (`f` par défaut), délivrée à la page comme 2. `input.performActions` — la touche configurée (`f` par défaut), délivrée à la page comme
un vrai évènement (`isTrusted: true`), donc traitée par le lecteur du site ; un vrai évènement (`isTrusted: true`), donc traitée par le lecteur du site ;
3. relecture de `document.fullscreenElement` ; 3. relecture de `document.fullscreenElement`, et verdict rapporté dans l'historique de la VM.
4. si la page n'a pas bougé : `requestFullscreen()` sur l'élément `<video>`, avec
`userActivation` ; Une seule voie, volontairement, sans repli. Une version antérieure appelait
5. relecture, et verdict rapporté dans l'historique de la VM. `requestFullscreen()` sur l'élément `<video>` brut quand `document.fullscreenElement` restait
vide après la touche — mais ce champ ne dit rien du mode « théâtre » de Stripchat, qui garde
la barre du haut visible et n'a le plus souvent rien à voir avec l'API plein écran native du
navigateur. Le repli déclenchait alors un vrai plein écran natif — vidéo seule, tout le reste
disparu — l'exact inverse de ce que la touche du lecteur, elle, obtient. `confirmed` reste
donc indéterminé (`undefined`, pas `false`) quand l'API native ne signale rien : ce n'est pas
un échec, juste un mode que ce champ ne peut pas observer.
**Un profil Firefox dédié est obligatoire**, pas cosmétique : le port de pilotage ne **Un profil Firefox dédié est obligatoire**, pas cosmétique : le port de pilotage ne
s'ouvre qu'au démarrage du processus, et deux instances ne peuvent pas partager un profil. s'ouvre qu'au démarrage du processus, et deux instances ne peuvent pas partager un profil.
@@ -281,19 +287,6 @@ Limite assumée : seuls les cookies sont copiés, pas le `localStorage` ni l'`In
quasi-totalité des sites, Stripchat compris, portent la session dans un cookie ; si un site quasi-totalité des sites, Stripchat compris, portent la session dans un cookie ; si un site
s'appuyait uniquement sur un jeton en stockage local, cet import ne suffirait pas. s'appuyait uniquement sur un jeton en stockage local, cet import ne suffirait pas.
#### Si le plein écran est refusé
Firefox refuse le plein écran à un document **dont la fenêtre n'a pas le focus**. C'est la
première cause d'échec sur une VM : il suffit qu'OBS ou un terminal l'ait pris. L'agent
relit alors trois conditions depuis la page et nomme celle qui manque, plutôt que de
répercuter un « Fullscreen request denied » qui ne dit rien :
| Message | Cause |
| --- | --- |
| `la fenêtre Firefox n'a pas le focus` | une autre fenêtre est active dans la session |
| `aucun élément vidéo dans la page` | le lecteur n'a pas fini de charger — augmente le délai avant plein écran |
| `l'API plein écran est désactivée dans ce profil` | `full-screen-api.enabled` forcé à faux |
#### Lancement simple #### Lancement simple
Conservé comme défaut pour ne pas changer le comportement d'un agent existant à la mise à Conservé comme défaut pour ne pas changer le comportement d'un agent existant à la mise à
@@ -370,6 +363,12 @@ puis ajuste la sortie vidéo (`SetVideoSettings`). Trois comportements à conna
qu'au lancement. L'agent le détecte et le signale dans son compte rendu. qu'au lancement. L'agent le détecte et le signale dans son compte rendu.
- **Un preset n'est jamais appliqué pendant une capture** : cela la corromprait. La - **Un preset n'est jamais appliqué pendant une capture** : cela la corromprait. La
demande est refusée, ou différée jusqu'à l'arrêt de l'enregistrement. demande est refusée, ou différée jusqu'à l'arrêt de l'enregistrement.
- **Un preset en attente est toujours écrit avant le `StartRecord` suivant.** Changer de
preset juste avant de lancer une capture (la sienne, ou celle d'un autre streamer sur la
même VM) ne dépend pas d'un minuteur en tâche de fond : le démarrage attend explicitement
que l'écriture soit passée. Sans cette garantie, une capture pouvait démarrer sur l'ancien
réglage et y rester verrouillée jusqu'à son propre arrêt — le symptôme perçu était « la
qualité ne se met jamais au maximum, et repart au défaut à chaque nouveau stream ».
Le mode de sortie du profil est forcé sur « Simple » : c'est la section que ces réglages Le mode de sortie du profil est forcé sur « Simple » : c'est la section que ces réglages
pilotent. Un paramètre refusé par la version d'OBS installée n'interrompt pas les autres — pilotent. Un paramètre refusé par la version d'OBS installée n'interrompt pas les autres —

View File

@@ -103,14 +103,17 @@ export class FirefoxController {
} }
/** /**
* Rappelle le plein écran, puis le vérifie. * Rappelle le plein écran en envoyant la touche du lecteur, et seulement elle.
* *
* Deux tentatives, dans cet ordre volontaire. La touche d'abord : c'est le * Volontairement une seule voie, sans repli. Une version antérieure appelait
* geste de l'opérateur, elle passe par le gestionnaire du lecteur et donne * `requestFullscreen()` sur l'élément vidéo brut quand la touche ne semblait
* exactement le cadrage attendu par le site. L'appel direct à * pas avoir agi — mais « ne semblait pas » se mesurait à
* `requestFullscreen` ensuite, seulement si la première n'a rien produit — * `document.fullscreenElement`, qui ne dit rien du bouton « théâtre » de
* il fonctionne toujours mais met en plein écran l'élément vidéo brut, sans * Stripchat : ce mode-là garde la barre du haut visible et n'a souvent rien
* l'habillage du lecteur. * à voir avec l'API plein écran native du navigateur. Le repli déclenchait
* alors un vrai plein écran natif — vidéo seule, tout le reste disparu —
* précisément l'effet que la touche du lecteur, elle, évite. Mieux vaut
* envoyer le geste attendu par le site et ne pas le corriger à l'aveugle.
*/ */
async restoreFullscreen(settings: FullscreenSettings): Promise<FullscreenOutcome> { async restoreFullscreen(settings: FullscreenSettings): Promise<FullscreenOutcome> {
await this.ensureReady(); await this.ensureReady();
@@ -139,57 +142,18 @@ export class FirefoxController {
// Le passage en plein écran est animé : relire trop tôt renverrait faux // Le passage en plein écran est animé : relire trop tôt renverrait faux
// alors que la touche a bien été prise en compte. // alors que la touche a bien été prise en compte.
await delay(1200); await delay(1200);
if (await this.isFullscreen()) {
this.fullscreen = true;
return { method: 'webdriver (touche)', target: this.url ?? undefined, confirmed: true };
}
this.log(
'info',
`La touche « ${settings.key} » n'a pas basculé la page ; tentative directe sur l'élément vidéo`,
);
const detail = await this.requestFullscreenOnVideo();
await delay(800);
const confirmed = await this.isFullscreen(); const confirmed = await this.isFullscreen();
this.fullscreen = confirmed; this.fullscreen = confirmed;
if (confirmed) {
return { method: 'webdriver (élément vidéo)', target: this.url ?? undefined, confirmed };
}
// « Fullscreen request denied » ne dit rien à personne. Les trois conditions // `confirmed` reste `undefined`, pas `false`, quand l'API native ne signale
// que Gecko exige sont lisibles depuis la page : on les relit pour nommer // rien : c'est le cas attendu pour un mode « théâtre » propre au site, pas
// celle qui manque, plutôt que de laisser l'opérateur deviner. // un échec. Le distinguer évite de journaliser en avertissement une touche
const why = await this.explainRefusal(detail); // qui a très bien pu faire son effet.
this.lastError = why; return {
return { method: `webdriver ${why}`, target: this.url ?? undefined, confirmed: false }; method: 'webdriver (touche)',
} target: this.url ?? undefined,
confirmed: confirmed ? true : undefined,
/** };
* Nomme la condition manquante après un refus de plein écran.
*
* Le focus est de loin la première cause : Firefox refuse le plein écran à un
* document dont la fenêtre n'est pas celle qu'a sélectionnée le gestionnaire
* de fenêtres — et sur une VM, il suffit qu'OBS ou un terminal l'ait pris.
*/
private async explainRefusal(detail: string): Promise<string> {
const raw = await this.evaluate(
'JSON.stringify({ focus: document.hasFocus(), api: document.fullscreenEnabled, video: !!document.querySelector("video") })',
).catch(() => null);
const page = typeof raw === 'string' ? (JSON.parse(raw) as Record<string, boolean>) : null;
if (!page) return detail;
if (!page.video) return 'aucun élément vidéo dans la page — le lecteur a-t-il fini de charger ?';
if (!page.focus) {
return (
"la fenêtre Firefox n'a pas le focus : Firefox refuse le plein écran à un onglet " +
"en arrière-plan. Ferme les autres fenêtres de la session, ou laisse Firefox seul " +
"au premier plan sur cette VM"
);
}
if (!page.api) return "l'API plein écran est désactivée dans ce profil Firefox";
return detail;
} }
/** Décharge le lecteur sans détruire la fenêtre — donc sans casser la source OBS. */ /** Décharge le lecteur sans détruire la fenêtre — donc sans casser la source OBS. */
@@ -494,30 +458,6 @@ export class FirefoxController {
return result === true; return result === true;
} }
/**
* Demande le plein écran sur l'élément vidéo.
*
* `userActivation` fait croire à Firefox à un geste utilisateur : sans lui,
* l'API refuse l'appel. Le profil pose aussi
* `full-screen-api.allow-trusted-requests-only=false`, pour les versions de
* Firefox qui ne connaissent pas encore ce paramètre de commande.
*/
private async requestFullscreenOnVideo(): Promise<string> {
const expression = `(async () => {
const video = document.querySelector('video');
if (!video) return 'aucune vidéo dans la page';
try {
await (video.requestFullscreen ? video.requestFullscreen() : Promise.reject(new Error('API absente')));
return 'ok';
} catch (err) {
return 'refus : ' + (err && err.message ? err.message : err);
}
})()`;
const result = await this.evaluate(expression, true).catch((err: Error) => err.message);
return typeof result === 'string' ? result : 'ok';
}
private async evaluate(expression: string, userActivation = false): Promise<unknown> { private async evaluate(expression: string, userActivation = false): Promise<unknown> {
const response = await this.send<{ const response = await this.send<{
type: string; type: string;

View File

@@ -1,18 +1,22 @@
/** /**
* Résultat d'un rappel de plein écran, quelle que soit la méthode employée. * Résultat d'un rappel de plein écran, quelle que soit la méthode employée.
* *
* `confirmed` est le champ qui compte. xdotool ne peut jamais le renseigner : * `confirmed` distingue trois cas, pas deux. xdotool ne peut jamais le
* il envoie une touche et n'a aucun moyen de savoir ce que la page en a fait. * renseigner (`undefined`) : il envoie une touche et n'a aucun moyen de
* BiDi, lui, relit `document.fullscreenElement` — donc un `false` ici est une * savoir ce que la page en a fait. BiDi relit `document.fullscreenElement`,
* information, pas une incertitude, et c'est ce qui permet d'enchaîner sur une * mais son absence ne vaut pas `false` pour autant : beaucoup de lecteurs —
* seconde tentative plutôt que de laisser l'enregistrement en fenêtré. * celui de Stripchat compris — implémentent leur propre mode « théâtre » en
* CSS, barre du haut toujours visible, sans jamais toucher à l'API plein
* écran native. `undefined` couvre donc aussi bien « aucun moyen de vérifier »
* que « vérifié négatif, mais rien ne prouve un échec » ; `false` ne
* s'emploie que là où l'échec est positivement établi.
*/ */
export interface FullscreenOutcome { export interface FullscreenOutcome {
/** `webdriver`, `xdotool`, `SendKeys`, `osascript`… */ /** `webdriver`, `xdotool`, `SendKeys`, `osascript`… */
method: string; method: string;
/** Titre de fenêtre ou URL, selon ce que la méthode a pu identifier. */ /** Titre de fenêtre ou URL, selon ce que la méthode a pu identifier. */
target?: string; target?: string;
/** Vérifié dans la page. `undefined` = la méthode ne sait pas vérifier. */ /** Vrai si confirmé, faux si l'échec est positivement établi, sinon indéterminé. */
confirmed?: boolean; confirmed?: boolean;
} }

View File

@@ -276,6 +276,12 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
}); });
} }
// Dernière chance d'écrire un preset en attente : une fois l'enregistrement
// commencé, OBS refuse toute modification jusqu'à l'arrêt. Sans cet appel
// explicite, l'écriture dépendait du hasard d'un cycle de statut (toutes les
// 2 s) tombant avant le démarrage — perdu, le preset restait en attente
// jusqu'au prochain arrêt, et cet enregistrement tournait sur l'ancien réglage.
await drainPresetApply();
await obs.execute('record.start'); await obs.execute('record.start');
report('info', `Capture démarrée pour ${url}`, 'capture.started'); report('info', `Capture démarrée pour ${url}`, 'capture.started');
@@ -348,6 +354,12 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
return startCapture(params); return startCapture(params);
case 'capture.stop': case 'capture.stop':
return stopCapture(); return stopCapture();
case 'record.start':
// Même raison que dans startCapture() : sans ce passage explicite, un
// preset changé juste avant restait en attente jusqu'au prochain arrêt,
// et cette capture démarrait sur l'ancien réglage.
await drainPresetApply();
return obs.execute('record.start');
case 'preset.apply': case 'preset.apply':
return applyPresetNow(params); return applyPresetNow(params);
case 'agent.update': case 'agent.update':