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

@@ -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 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.
#### 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 #### 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 à

View File

@@ -7,12 +7,24 @@ import type {
BrowserState, BrowserState,
FullscreenSettings, FullscreenSettings,
SessionImportResult, SessionImportResult,
StreamQualitySettings,
} from '@stream-control/shared'; } from '@stream-control/shared';
import { BidiClient } from './bidi.ts'; import { BidiClient } from './bidi.ts';
import { assertWebUrl, type BrowserLog } from './browser.ts'; import { assertWebUrl, type BrowserLog } from './browser.ts';
import type { FullscreenOutcome } from './fullscreen.ts'; import type { FullscreenOutcome } from './fullscreen.ts';
import { sessionEnv } from './x11.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. * 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. */ /** Décharge le lecteur sans détruire la fenêtre — donc sans casser la source OBS. */
async blank(): Promise<{ url: string }> { async blank(): Promise<{ url: string }> {
if (!this.client?.isOpen) { if (!this.client?.isOpen) {

View File

@@ -33,7 +33,7 @@ import { ObsController } from './obs.ts';
import { StreamWatcher } from './watcher.ts'; import { StreamWatcher } from './watcher.ts';
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts'; import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.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 { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts';
import { sendHotkey } from './hotkey.ts'; import { sendHotkey } from './hotkey.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.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`); report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`);
await sleep(wait); await sleep(wait);
await selectMaxStreamQuality();
const fullscreen = watcher.snapshotSettings.fullscreen; const fullscreen = watcher.snapshotSettings.fullscreen;
let fullscreenResult: unknown = 'ignoré'; let fullscreenResult: unknown = 'ignoré';
if (fullscreen.enabled) { if (fullscreen.enabled) {
@@ -412,6 +414,36 @@ async function restoreFullscreen(settings: FullscreenSettings): Promise<Fullscre
return sendHotkey({ key: settings.key, windowMatch: settings.windowMatch }); 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 ------------------------------------------------ // --- Presets d'enregistrement ------------------------------------------------
/** /**

View File

@@ -336,6 +336,25 @@ export interface FullscreenSettings {
delayMs: number; 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 { export interface WatchSettings {
enabled: boolean; enabled: boolean;
provider: WatchProvider; provider: WatchProvider;
@@ -375,6 +394,7 @@ export interface WatchSettings {
*/ */
offlineStopDelayMs: number; offlineStopDelayMs: number;
fullscreen: FullscreenSettings; fullscreen: FullscreenSettings;
streamQuality: StreamQualitySettings;
} }
/** /**
@@ -540,6 +560,10 @@ export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
windowMatch: 'Stripchat', windowMatch: 'Stripchat',
delayMs: 4000, delayMs: 4000,
}, },
streamQuality: {
enabled: true,
delayMs: 3000,
},
}; };
/** État courant de la surveillance, remonté avec le statut de l'agent. */ /** État courant de la surveillance, remonté avec le statut de l'agent. */
@@ -705,6 +729,8 @@ export const AGENT_EVENTS = [
'watch.failed', 'watch.failed',
'fullscreen.restored', 'fullscreen.restored',
'fullscreen.failed', 'fullscreen.failed',
'quality.selected',
'quality.failed',
'browser.opened', 'browser.opened',
'browser.closed', 'browser.closed',
'browser.imported', 'browser.imported',
@@ -917,6 +943,7 @@ export function normalizeWatchSettings(raw: unknown): WatchSettings {
const input = (raw ?? {}) as Partial<WatchSettings>; const input = (raw ?? {}) as Partial<WatchSettings>;
const base = DEFAULT_WATCH_SETTINGS; const base = DEFAULT_WATCH_SETTINGS;
const fullscreen = (input.fullscreen ?? {}) as Partial<FullscreenSettings>; const fullscreen = (input.fullscreen ?? {}) as Partial<FullscreenSettings>;
const streamQuality = (input.streamQuality ?? {}) as Partial<StreamQualitySettings>;
const clamp = (value: unknown, fallback: number, min: number, max: number): number => { const clamp = (value: unknown, fallback: number, min: number, max: number): number => {
const parsed = Number(value); const parsed = Number(value);
@@ -955,6 +982,10 @@ export function normalizeWatchSettings(raw: unknown): WatchSettings {
: base.fullscreen.windowMatch, : base.fullscreen.windowMatch,
delayMs: clamp(fullscreen.delayMs, base.fullscreen.delayMs, 0, 120_000), 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),
},
}; };
} }

View File

@@ -44,6 +44,8 @@ const EVENT_STYLES: Record<AgentEvent, EventStyle> = {
'watch.failed': { icon: '!', tone: 'warn' }, 'watch.failed': { icon: '!', tone: 'warn' },
'fullscreen.restored': { icon: '⛶', tone: 'ok' }, 'fullscreen.restored': { icon: '⛶', tone: 'ok' },
'fullscreen.failed': { icon: '⛶', tone: 'warn' }, 'fullscreen.failed': { icon: '⛶', tone: 'warn' },
'quality.selected': { icon: '◆', tone: 'ok' },
'quality.failed': { icon: '◆', tone: 'warn' },
'browser.opened': { icon: '⊞', tone: 'info' }, 'browser.opened': { icon: '⊞', tone: 'info' },
'browser.closed': { icon: '⊟', tone: 'offline' }, 'browser.closed': { icon: '⊟', tone: 'offline' },
'browser.imported': { icon: '⇄', tone: 'ok' }, 'browser.imported': { icon: '⇄', tone: 'ok' },
@@ -68,7 +70,7 @@ type FilterId = (typeof FILTERS)[number]['id'];
const FILTER_PREFIXES: Record<Exclude<FilterId, 'all' | 'issues'>, string[]> = { const FILTER_PREFIXES: Record<Exclude<FilterId, 'all' | 'issues'>, string[]> = {
record: ['record.', 'capture.', 'stream.'], record: ['record.', 'capture.', 'stream.'],
obs: ['obs.', 'preset.', 'config.'], obs: ['obs.', 'preset.', 'config.'],
watch: ['watch.', 'fullscreen.', 'browser.'], watch: ['watch.', 'fullscreen.', 'browser.', 'quality.'],
}; };
function matches(entry: LogEntry, filter: FilterId): boolean { function matches(entry: LogEntry, filter: FilterId): boolean {

View File

@@ -110,6 +110,13 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
setWatch((current) => ({ ...current, fullscreen: { ...current.fullscreen, ...patch } })); setWatch((current) => ({ ...current, fullscreen: { ...current.fullscreen, ...patch } }));
} }
function patchStreamQuality(patch: Partial<WatchSettings['streamQuality']>) {
setWatch((current) => ({
...current,
streamQuality: { ...current.streamQuality, ...patch },
}));
}
/** /**
* Applique le preset actuellement sélectionné, enregistré ou non : c'est un * Applique le preset actuellement sélectionné, enregistré ou non : c'est un
* essai, on veut voir le compte rendu avant de figer le choix. * 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 Tester le plein écran
</button> </button>
</div> </div>
{browser.mode === 'bidi' && (
<>
<label className="checkbox">
<input
type="checkbox"
checked={watch.streamQuality.enabled}
onChange={(event) => patchStreamQuality({ enabled: event.target.checked })}
/>
<span>
Sélectionner la meilleure qualité du lecteur à l'ouverture de chaque stream
<span className="muted small">
{' '}
— Stripchat ne démarre pas toujours sur son maximum disponible.
</span>
</span>
</label>
{watch.streamQuality.enabled && (
<label className="field small-field">
<span>Délai avant réglage (s)</span>
<input
value={String(Math.round(watch.streamQuality.delayMs / 1000))}
onChange={(event) =>
patchStreamQuality({ delayMs: (Number(event.target.value) || 0) * 1000 })
}
inputMode="numeric"
/>
<span className="muted small">Le temps que le lecteur s'initialise.</span>
</label>
)}
</>
)}
</fieldset> </fieldset>
<div className="danger-zone"> <div className="danger-zone">