Feat : Change quality
All checks were successful
release / build (push) Successful in 25s
release / verify-windows (push) Successful in 1m14s

This commit is contained in:
jeanotx32
2026-08-12 04:55:56 +02:00
parent ea8eeec129
commit d14369a914
6 changed files with 225 additions and 2 deletions

View File

@@ -7,12 +7,24 @@ import type {
BrowserState,
FullscreenSettings,
SessionImportResult,
StreamQualitySettings,
} from '@stream-control/shared';
import { BidiClient } from './bidi.ts';
import { assertWebUrl, type BrowserLog } from './browser.ts';
import type { FullscreenOutcome } from './fullscreen.ts';
import { sessionEnv } from './x11.ts';
/** Compte rendu de {@link FirefoxController.selectMaxStreamQuality}. */
export interface StreamQualityOutcome {
ok: boolean;
/** Valeur choisie (`1080p`…), présente seulement si `ok`. */
label?: string;
/** Déjà sur ce choix avant l'appel — rien n'a donc été cliqué. */
already?: boolean;
/** Raison de l'échec, présente seulement si `!ok`. */
reason?: string;
}
/**
* Attente maximale de l'ouverture du port de pilotage.
*
@@ -156,6 +168,84 @@ export class FirefoxController {
};
}
/**
* Sélectionne la meilleure qualité offerte par le lecteur Stripchat.
*
* 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.
*
* Couplé à la structure actuelle du lecteur (`.player-resolution`,
* `.player-resolution-tooltip__button--resolution`) : sans effet, mais sans
* erreur non plus, si Stripchat change son balisage — signalé dans le
* compte rendu plutôt que dans une exception.
*/
async selectMaxStreamQuality(): Promise<StreamQualityOutcome> {
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 }),
);
try {
return JSON.parse(typeof raw === 'string' ? raw : String(raw)) as StreamQualityOutcome;
} catch {
return { ok: false, reason: 'réponse illisible' };
}
}
/** Décharge le lecteur sans détruire la fenêtre — donc sans casser la source OBS. */
async blank(): Promise<{ url: string }> {
if (!this.client?.isOpen) {

View File

@@ -33,7 +33,7 @@ import { ObsController } from './obs.ts';
import { StreamWatcher } from './watcher.ts';
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts';
import { FirefoxController } from './firefox.ts';
import { FirefoxController, type StreamQualityOutcome } from './firefox.ts';
import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts';
import { sendHotkey } from './hotkey.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
@@ -256,6 +256,8 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`);
await sleep(wait);
await selectMaxStreamQuality();
const fullscreen = watcher.snapshotSettings.fullscreen;
let fullscreenResult: unknown = 'ignoré';
if (fullscreen.enabled) {
@@ -412,6 +414,36 @@ async function restoreFullscreen(settings: FullscreenSettings): Promise<Fullscre
return sendHotkey({ key: settings.key, windowMatch: settings.windowMatch });
}
/**
* Sélectionne la meilleure qualité offerte par le lecteur Stripchat.
*
* Sans effet hors mode BiDi : il n'existe aucun moyen d'interagir avec le
* contenu de la page sans lui. Un échec ne bloque jamais l'enregistrement —
* mieux vaut capturer une qualité non maximale que ne rien capturer du tout.
*/
async function selectMaxStreamQuality(): Promise<void> {
const bidi = driver();
const settings = watcher.snapshotSettings.streamQuality;
if (!bidi || !settings.enabled) return;
await sleep(settings.delayMs);
const result: StreamQualityOutcome = await bidi
.selectMaxStreamQuality()
.catch((err: Error) => ({ ok: false, reason: err.message }));
if (!result.ok) {
report('warn', `Qualité du flux non réglée : ${result.reason}`, 'quality.failed');
return;
}
report(
'info',
result.already
? `Qualité du flux déjà au maximum (${result.label})`
: `Qualité du flux réglée sur ${result.label}`,
'quality.selected',
);
}
// --- Presets d'enregistrement ------------------------------------------------
/**