Feat : Change quality
This commit is contained in:
28
README.md
28
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
|
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 à
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 ------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user