Files
javis_bot/bot/scripts/stream-test/scenario.mjs
javis-bot f93b241575 fix(stream-test): restore audio after ads, enforce subtitle rule broadcast-wide, commit the 60fps MV path
Addresses review of the ad/subtitle work (the ad-skip.mjs -> broadcast-helper.mjs
rename's other half; the prior commit only recorded the deletion):

- ad mute leak: the ad-skipper muted during an ad but never un-muted, so the
  main video stayed silent after the first ad. Save the pre-ad muted/playbackRate
  and restore them when the ad ends (verified: muted false -> true -> false).
- captions were only applied once when scenario.mjs ran, not for the whole
  broadcast. The persistent helper now applies the rule (OFF by default, Korean
  ON if offered) per video and ENFORCES it every tick - one-shot did not hold
  because YouTube silently re-enabled captions (verified it stays off across 8s).
- ad-skip + captions merged into broadcast-helper.mjs (one CDP process).
- the 60fps MV test now lives in the repo: scenario.mjs gains MV_QUERY (search +
  auto-pick the first >=60fps result) and WATCH_SECONDS, plus the
  fullscreen-toolbar-hide fix. The broadcast runs via the committed
  stream-hold.ts (audio + keepalive), not an out-of-repo copy.
- document the test env vars (CDP_PORT, HOLD_MS, TEST_*, MV_QUERY, WATCH_SECONDS).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 16:09:31 +09:00

166 lines
7.9 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 || '아이유';
// MV_QUERY mode: search this query and auto-pick the first result that actually
// reports >=60fps (instead of clicking the fixed TEST_VIDEO_ID). WATCH_SECONDS
// is how long to watch windowed and fullscreen (default 20).
const MV_QUERY = process.env.MV_QUERY || '';
const WATCH_MS = parseInt(process.env.WATCH_SECONDS || '20', 10) * 1000;
// Subtitles (off-by-default, Korean-on) and YouTube ad-skipping are applied
// broadcast-wide by broadcast-helper.mjs, not here.
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 */ }
}
const fpsNow = () => read(() => {
try { const s = document.getElementById('movie_player').getStatsForNerds(); const m = (s.resolution || '').match(/@(\d+)/); return m ? +m[1] : null; } catch { return null; }
});
async function skipAdsQuick() {
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;
}
}
// 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 (fixed query, or the MV query)
await humanClick(page, page.locator('input#search, input[name=search_query]').first());
await humanType(MV_QUERY || SEARCH);
await humanKey('Return');
await sleep(3800);
// open a result with the real mouse, wait for the player, skip ads, ensure playing
async function openAndPlay(link) {
await humanClick(page, link);
await sleep(3500);
await page.waitForSelector('#movie_player', { timeout: 25000 }); await sleep(2000);
await skipAdsQuick();
if (await read(() => document.querySelector('video')?.paused)) {
await humanClick(page, page.locator('.ytp-large-play-button, .ytp-play-button').first());
}
await sleep(1500);
}
// 3) pick the video: in MV mode auto-pick the first result that really reports
// >=60fps; otherwise click the fixed concert clip.
if (MV_QUERY) {
const resultsUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(MV_QUERY)}&sp=EgQQARgD`;
let picked = false;
for (let i = 0; i < 5 && !picked; i++) {
const results = page.locator('ytd-video-renderer a#video-title, ytd-rich-item-renderer a#video-title');
if (!(await results.nth(i).count().catch(() => 0))) break;
await openAndPlay(results.nth(i));
const fps = await fpsNow();
console.log(`MV candidate ${i} fps=${fps}`);
if (fps && fps >= 60) { picked = true; break; }
await navigateOmnibox(resultsUrl); await sleep(3000);
}
if (!picked) console.log('MV: no >=60fps result found, using last opened');
} else {
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 openAndPlay(link);
}
// 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());
// 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(WATCH_MS);
// 7) fullscreen: hide the browser toolbar (CDP), then the real 'f' key makes the
// video fill the now toolbar-free screen (innerHeight 1080).
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(WATCH_MS);
// 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();