Two broadcast-experience improvements: - Audio: the Go-Live stream was video-only. Capture the desktop sound (the default PipeWire/Pulse sink monitor, @DEFAULT_MONITOR@) as a second ffmpeg input and mux AAC into the mpegts; the library re-encodes it to Opus for Discord. Controlled by STREAM_AUDIO / STREAM_AUDIO_SOURCE (default on). ffmpeg inherits XDG_RUNTIME_DIR to reach the pulse socket. Verified: the streamer now reports "Found audio stream" and the monitor carries Chrome audio (~-11 dB). - Subtitles: in the browse scenario, default captions OFF, but auto-enable a Korean track when the video offers one (getOption captions tracklist -> setOption / unloadModule). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
148 lines
7.0 KiB
JavaScript
148 lines
7.0 KiB
JavaScript
// Browse scenario driven ENTIRELY with real mouse/keyboard input via xdotool
|
|
// (see human.mjs). Connects to a Chrome already running with
|
|
// --remote-debugging-port (default 9222) on the streamed X display.
|
|
//
|
|
// All ACTIONS are real input: address-bar navigation (Ctrl+L + typing),
|
|
// search typing, clicking the video, the settings gear -> 화질 -> 1080p menu,
|
|
// the autoplay toggle, the play button, fullscreen via the 'f' key, scrolling,
|
|
// and entering 나무위키. The CDP/DOM API is used ONLY to read state for
|
|
// verification (paused/quality/fullscreen) and as a rare click fallback when an
|
|
// element has no on-screen box.
|
|
//
|
|
// One environment workaround: on the streamed VNC desktop (xfwm4) Chrome does
|
|
// NOT hide its toolbar when a video enters HTML5 fullscreen ('f'), so the
|
|
// address bar bleeds into the broadcast. We therefore toggle the BROWSER window
|
|
// into Chrome-initiated fullscreen via CDP (Browser.setWindowBounds) around the
|
|
// 'f' step - that reliably hides the toolbar (innerHeight 1080 vs 988) - then
|
|
// restore it. This is a window-chrome action, not a page interaction.
|
|
import { chromium } from 'playwright';
|
|
import { humanClick, humanType, humanKey, humanHover, navigateOmnibox, humanScroll, sleep } from './human.mjs';
|
|
|
|
const CDP = process.env.CDP_PORT || '9222';
|
|
const VID = process.env.TEST_VIDEO_ID || 'X_am71G6Vy4';
|
|
const SEARCH = process.env.TEST_YT_QUERY || '내손을잡아';
|
|
const NAVER_Q = process.env.TEST_NAVER_QUERY || '아이유';
|
|
|
|
const b = await chromium.connectOverCDP(`http://localhost:${CDP}`);
|
|
const ctx = b.contexts()[0];
|
|
const page = ctx.pages()[0];
|
|
page.setDefaultTimeout(25000);
|
|
const read = (fn) => page.evaluate(fn);
|
|
const playerLoc = () => page.locator('#movie_player');
|
|
|
|
// Toggle Chrome-initiated browser fullscreen (hides the toolbar on this WM,
|
|
// which HTML5 'f' fullscreen alone does not). on=true -> fullscreen.
|
|
async function browserFullscreen(on) {
|
|
try {
|
|
const sess = await b.newBrowserCDPSession();
|
|
const { targetInfos } = await sess.send('Target.getTargets');
|
|
const t = targetInfos.find((x) => x.type === 'page' && x.url.startsWith('http'));
|
|
if (!t) return;
|
|
const { windowId } = await sess.send('Browser.getWindowForTarget', { targetId: t.targetId });
|
|
await sess.send('Browser.setWindowBounds', { windowId, bounds: { windowState: on ? 'fullscreen' : 'normal' } });
|
|
} catch { /* best-effort */ }
|
|
}
|
|
|
|
// Subtitles: OFF by default, but turn ON Korean when a Korean track exists.
|
|
async function applyCaptions() {
|
|
await read(() => { try { document.getElementById('movie_player')?.loadModule?.('captions'); } catch {} });
|
|
await sleep(800);
|
|
return read(() => {
|
|
const pl = document.getElementById('movie_player');
|
|
if (!pl || !pl.getOption) return 'no-player';
|
|
let tracks = [];
|
|
try { tracks = pl.getOption('captions', 'tracklist') || []; } catch {}
|
|
const ko = tracks.find((t) => /^ko/i.test(t.languageCode || ''));
|
|
if (ko) { try { pl.setOption('captions', 'track', { languageCode: ko.languageCode }); } catch {} return 'ko-on'; }
|
|
try { pl.setOption('captions', 'track', {}); } catch {}
|
|
try { pl.unloadModule('captions'); } catch {}
|
|
return 'off';
|
|
});
|
|
}
|
|
|
|
// 1) open YouTube by typing the URL in the address bar
|
|
await navigateOmnibox('https://www.youtube.com'); await sleep(3000);
|
|
|
|
// 2) really type the search and submit
|
|
await humanClick(page, page.locator('input#search, input[name=search_query]').first());
|
|
await humanType(SEARCH);
|
|
await humanKey('Return');
|
|
await sleep(3800);
|
|
|
|
// 3) click the IU concert result with the real mouse
|
|
let link = page.locator(`a#video-title[href*="${VID}"], a[href*="${VID}"]`).first();
|
|
if (!(await link.count().catch(() => 0))) link = page.locator('ytd-video-renderer a#video-title, ytd-rich-item-renderer a#video-title').first();
|
|
await humanClick(page, link);
|
|
await sleep(3500);
|
|
await page.waitForSelector('#movie_player', { timeout: 25000 }); await sleep(2000);
|
|
for (let i = 0; i < 8; i++) { const ad = page.locator('.ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-skip-ad-button'); if (await ad.count().catch(() => 0)) { await humanClick(page, ad.first()); await sleep(1200); } else break; }
|
|
|
|
// 4) if paused, press play with the real mouse
|
|
if (await read(() => document.querySelector('video')?.paused)) {
|
|
const big = page.locator('.ytp-large-play-button, .ytp-play-button').first();
|
|
await humanClick(page, big);
|
|
}
|
|
await sleep(1500);
|
|
|
|
// 5) set 1080p through the real settings menu (gear -> 화질 -> 1080p), verify
|
|
async function setQuality1080() {
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
await humanHover(page, playerLoc());
|
|
await humanClick(page, page.locator('.ytp-settings-button')); await sleep(900);
|
|
let qrow = page.locator('.ytp-menuitem', { hasText: /화질|Quality/ }).first();
|
|
if (!(await qrow.count().catch(() => 0))) qrow = page.locator('.ytp-panel-menu .ytp-menuitem').last();
|
|
await humanClick(page, qrow); await sleep(900);
|
|
const item = page.locator('.ytp-menuitem', { hasText: /1080/ }).first();
|
|
if (await item.count().catch(() => 0)) await humanClick(page, item);
|
|
await sleep(2000);
|
|
const q = await read(() => document.getElementById('movie_player')?.getPlaybackQuality?.());
|
|
if (q && /1080/.test(q)) return q;
|
|
}
|
|
return null;
|
|
}
|
|
console.log('QUALITY', await setQuality1080());
|
|
console.log('CAPTIONS', await applyCaptions());
|
|
|
|
// 6) turn off autoplay with a real click if it is on
|
|
const auto = page.locator('.ytp-autonav-toggle-button');
|
|
if ((await auto.count().catch(() => 0)) && (await auto.getAttribute('aria-checked').catch(() => null)) === 'true') {
|
|
await humanHover(page, playerLoc());
|
|
await humanClick(page, auto);
|
|
}
|
|
console.log('STEP watch-1080-windowed'); await sleep(20000);
|
|
|
|
// 7) fullscreen: hide the browser toolbar (CDP), then the real 'f' key makes the
|
|
// video fill the now toolbar-free screen (innerHeight 1080). 20s.
|
|
await browserFullscreen(true); await sleep(800);
|
|
await humanHover(page, playerLoc());
|
|
await humanKey('f'); await sleep(1500);
|
|
if (!(await read(() => !!document.fullscreenElement))) { await humanHover(page, playerLoc()); await humanKey('f'); await sleep(1200); }
|
|
console.log('STEP fullscreen', await read(() => ({ full: !!document.fullscreenElement, h: window.innerHeight }))); await sleep(20000);
|
|
|
|
// 8) exit video fullscreen ('f'), then restore the browser toolbar
|
|
await humanKey('f'); await sleep(1500);
|
|
await browserFullscreen(false); await sleep(500);
|
|
|
|
// 9) Naver via the address bar, then really type the query
|
|
await navigateOmnibox('https://www.naver.com'); await sleep(2800);
|
|
await humanClick(page, page.locator('input#query').first());
|
|
await humanType(NAVER_Q);
|
|
await humanKey('Return');
|
|
await sleep(2800);
|
|
await humanScroll(page, +1, 18);
|
|
console.log('STEP naver-scrolled');
|
|
|
|
// 10) enter 나무위키 with a real click, then scroll
|
|
const namu = page.locator('a[href*="namu.wiki"]').first();
|
|
if (await namu.count().catch(() => 0)) {
|
|
await humanClick(page, namu);
|
|
await sleep(3000);
|
|
await humanScroll(page, +1, 14);
|
|
await humanScroll(page, -1, 8);
|
|
await humanScroll(page, +1, 10);
|
|
console.log('STEP namu-scrolled');
|
|
} else console.log('STEP namu-not-found');
|
|
|
|
console.log('SCENARIO_DONE');
|
|
await b.close();
|