From d14369a914041b62fd382c1a2eddbcff35f96719 Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Wed, 12 Aug 2026 04:55:56 +0200 Subject: [PATCH] Feat : Change quality --- README.md | 28 ++++++ packages/agent/src/firefox.ts | 90 +++++++++++++++++++ packages/agent/src/index.ts | 34 ++++++- packages/shared/src/index.ts | 31 +++++++ packages/web/src/components/AgentHistory.tsx | 4 +- packages/web/src/components/AgentSettings.tsx | 40 +++++++++ 6 files changed, 225 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a76423a..4c67a97 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,34 @@ 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 s'appuyait uniquement sur un jeton en stockage local, cet import ne suffirait pas. +#### Qualité du flux Stripchat + +À ne pas confondre avec les presets d'enregistrement ci-dessus : ceux-là règlent ce qu'**OBS** +écrit sur disque ; ce réglage-ci règle ce que le **lecteur Stripchat** affiche à l'écran, donc +ce qu'OBS capture en premier lieu. Un preset au maximum ne sert à rien si le lecteur diffuse +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 : + +```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 +``` + +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 +verticaux, dont la hauteur source ne suit pas l'échelle standard 240/480/720/1080 — ne +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. + #### Lancement simple Conservé comme défaut pour ne pas changer le comportement d'un agent existant à la mise à diff --git a/packages/agent/src/firefox.ts b/packages/agent/src/firefox.ts index 6a66e46..439634f 100644 --- a/packages/agent/src/firefox.ts +++ b/packages/agent/src/firefox.ts @@ -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 { + 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) { diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index f67a139..3fb3261 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -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): Promise { 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 { + 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 ------------------------------------------------ /** diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b9947a1..43886fc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -336,6 +336,25 @@ export interface FullscreenSettings { delayMs: number; } +/** + * Sélection automatique de la meilleure qualité offerte par le lecteur Stripchat. + * + * Stripchat ne démarre pas toujours sur sa meilleure qualité disponible — la + * valeur active par défaut varie, et rien ne garantit qu'elle corresponde au + * maximum réellement proposé pour ce streamer. Chaque nouvelle page (donc + * chaque nouveau stream ouvert) repart de ce choix du site, pas du précédent. + * + * Couplé à la structure actuelle du lecteur Stripchat (`.player-resolution`, + * `.player-resolution-tooltip__button--resolution`) : n'a d'effet qu'en mode + * de pilotage WebDriver BiDi, et se limite à ne rien faire si le site change + * son balisage. + */ +export interface StreamQualitySettings { + enabled: boolean; + /** Délai après le chargement de la page, le temps que le lecteur s'initialise. */ + delayMs: number; +} + export interface WatchSettings { enabled: boolean; provider: WatchProvider; @@ -375,6 +394,7 @@ export interface WatchSettings { */ offlineStopDelayMs: number; fullscreen: FullscreenSettings; + streamQuality: StreamQualitySettings; } /** @@ -540,6 +560,10 @@ export const DEFAULT_WATCH_SETTINGS: WatchSettings = { windowMatch: 'Stripchat', delayMs: 4000, }, + streamQuality: { + enabled: true, + delayMs: 3000, + }, }; /** État courant de la surveillance, remonté avec le statut de l'agent. */ @@ -705,6 +729,8 @@ export const AGENT_EVENTS = [ 'watch.failed', 'fullscreen.restored', 'fullscreen.failed', + 'quality.selected', + 'quality.failed', 'browser.opened', 'browser.closed', 'browser.imported', @@ -917,6 +943,7 @@ export function normalizeWatchSettings(raw: unknown): WatchSettings { const input = (raw ?? {}) as Partial; const base = DEFAULT_WATCH_SETTINGS; const fullscreen = (input.fullscreen ?? {}) as Partial; + const streamQuality = (input.streamQuality ?? {}) as Partial; const clamp = (value: unknown, fallback: number, min: number, max: number): number => { const parsed = Number(value); @@ -955,6 +982,10 @@ export function normalizeWatchSettings(raw: unknown): WatchSettings { : base.fullscreen.windowMatch, delayMs: clamp(fullscreen.delayMs, base.fullscreen.delayMs, 0, 120_000), }, + streamQuality: { + enabled: streamQuality.enabled !== false, + delayMs: clamp(streamQuality.delayMs, base.streamQuality.delayMs, 0, 120_000), + }, }; } diff --git a/packages/web/src/components/AgentHistory.tsx b/packages/web/src/components/AgentHistory.tsx index 22950a6..43c0950 100644 --- a/packages/web/src/components/AgentHistory.tsx +++ b/packages/web/src/components/AgentHistory.tsx @@ -44,6 +44,8 @@ const EVENT_STYLES: Record = { 'watch.failed': { icon: '!', tone: 'warn' }, 'fullscreen.restored': { icon: '⛶', tone: 'ok' }, 'fullscreen.failed': { icon: '⛶', tone: 'warn' }, + 'quality.selected': { icon: '◆', tone: 'ok' }, + 'quality.failed': { icon: '◆', tone: 'warn' }, 'browser.opened': { icon: '⊞', tone: 'info' }, 'browser.closed': { icon: '⊟', tone: 'offline' }, 'browser.imported': { icon: '⇄', tone: 'ok' }, @@ -68,7 +70,7 @@ type FilterId = (typeof FILTERS)[number]['id']; const FILTER_PREFIXES: Record, string[]> = { record: ['record.', 'capture.', 'stream.'], obs: ['obs.', 'preset.', 'config.'], - watch: ['watch.', 'fullscreen.', 'browser.'], + watch: ['watch.', 'fullscreen.', 'browser.', 'quality.'], }; function matches(entry: LogEntry, filter: FilterId): boolean { diff --git a/packages/web/src/components/AgentSettings.tsx b/packages/web/src/components/AgentSettings.tsx index b94e69b..bd769ee 100644 --- a/packages/web/src/components/AgentSettings.tsx +++ b/packages/web/src/components/AgentSettings.tsx @@ -110,6 +110,13 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr setWatch((current) => ({ ...current, fullscreen: { ...current.fullscreen, ...patch } })); } + function patchStreamQuality(patch: Partial) { + setWatch((current) => ({ + ...current, + streamQuality: { ...current.streamQuality, ...patch }, + })); + } + /** * Applique le preset actuellement sélectionné, enregistré ou non : c'est un * essai, on veut voir le compte rendu avant de figer le choix. @@ -736,6 +743,39 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr Tester le plein écran + + {browser.mode === 'bidi' && ( + <> + + + {watch.streamQuality.enabled && ( + + )} + + )}