diff --git a/.env.example b/.env.example index 867e6f7..2818d15 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,20 @@ NOVNC_URL= # --- screenshot backend --- SCREENSHOT_INTERVAL_SEC=5 +# --- stream-test scripts (manual broadcast verification) --- +# Chrome remote-debugging port the scenario/helper attach to. +CDP_PORT=9222 +# How long stream-hold.ts keeps the Go-Live up before auto-stopping (ms, 2h). +HOLD_MS=7200000 +# scenario.mjs inputs: fixed concert clip + search queries. +TEST_VIDEO_ID=X_am71G6Vy4 +TEST_YT_QUERY=내손을잡아 +TEST_NAVER_QUERY=아이유 +# Set MV_QUERY to auto-pick the first >=60fps search result instead of the clip. +MV_QUERY= +# Windowed/fullscreen watch duration per step (seconds). +WATCH_SECONDS=20 + # --------------------------------------------------------------------------- # Voice behaviour # --------------------------------------------------------------------------- diff --git a/bot/scripts/stream-test/README.md b/bot/scripts/stream-test/README.md index 36b3572..e928b08 100644 --- a/bot/scripts/stream-test/README.md +++ b/bot/scripts/stream-test/README.md @@ -18,27 +18,32 @@ real browsing session captured from the X display. (no DOM scrollIntoView); if an element has no on-screen box the click fails rather than falling back to a synthetic click. The CDP/DOM API is used only to read state for verification, never to act. -- `scenario.mjs` - the browse scenario (YouTube -> IU live -> 1080p -> - fullscreen -> Naver -> 나무위키), driven with the human helpers. Connects to a - Chrome already running with `--remote-debugging-port` (`CDP_PORT`, default - 9222) on the streamed display. Captions default OFF, auto-enabling a Korean - track when one exists. -- `ad-skip.mjs` - persistent YouTube ad auto-skipper. Connects over CDP and - injects a watcher into every tab (current and future) that clicks "Skip ad" - the instant it appears, closes overlay ads, and fast-forwards unskippable ads - (seek-to-end + 16x + mute). Run it alongside the broadcast. Reconnects across - Chrome restarts. +- `scenario.mjs` - the browse scenario (YouTube -> 1080p -> fullscreen -> Naver + -> 나무위키), driven with the human helpers. Connects to a Chrome already + running with `--remote-debugging-port` (`CDP_PORT`, default 9222) on the + streamed display. Defaults to a fixed concert clip; set `MV_QUERY` to instead + search and auto-pick the first result that really reports >=60fps. `WATCH_SECONDS` + (default 20) sets the windowed/fullscreen watch durations. +- `broadcast-helper.mjs` - persistent CDP helper that injects one watcher into + every tab (current and future) and (1) auto-skips YouTube ads - clicks "Skip + ad" instantly, closes overlay ads, fast-forwards unskippable ads (seek-to-end + + 16x + mute) and RESTORES the pre-ad muted/playbackRate when the ad ends; and + (2) applies the subtitle rule per video: captions OFF by default, Korean ON + when the video offers a Korean track. Run it alongside the broadcast; it + reconnects across Chrome restarts. ## Run ``` # keep the broadcast up (separate process / service) bun bot/scripts/stream-test/stream-hold.ts -# Chrome on the streamed display with remote debugging, then: -node bot/scripts/stream-test/scenario.mjs +# keep ads auto-skipped + subtitles correct for the whole broadcast: +node bot/scripts/stream-test/broadcast-helper.mjs -# keep YouTube ads auto-skipped for the whole broadcast (separate process): -node bot/scripts/stream-test/ad-skip.mjs +# Chrome on the streamed display with remote debugging, then run a browse pass: +node bot/scripts/stream-test/scenario.mjs +# ...or the 60fps MV variant: +MV_QUERY="4K 60fps MV" WATCH_SECONDS=30 node bot/scripts/stream-test/scenario.mjs ``` Recommended Chrome flags on the streamed display (avoids the "restore pages?" diff --git a/bot/scripts/stream-test/broadcast-helper.mjs b/bot/scripts/stream-test/broadcast-helper.mjs new file mode 100644 index 0000000..6209f7c --- /dev/null +++ b/bot/scripts/stream-test/broadcast-helper.mjs @@ -0,0 +1,102 @@ +// Persistent broadcast browser helper. Connects over CDP and injects one +// watcher into every tab (current and future) that: +// 1. Auto-skips YouTube ads - clicks "Skip ad" the instant it appears, closes +// overlay ads, and fast-forwards unskippable ads (seek-to-end + 16x + mute) +// so they clear in ~1s. The pre-ad muted/playbackRate are SAVED and +// RESTORED when the ad ends, so the main video is never left muted/fast. +// 2. Applies the subtitle rule per video: captions OFF by default, but a +// Korean track is turned ON when the video offers one. Runs once per video. +// Self-contained: no extension, no network/hosts changes. Reconnects across +// Chrome restarts. +// +// node bot/scripts/stream-test/broadcast-helper.mjs (CDP_PORT, default 9222) +import { chromium } from 'playwright'; + +const CDP = process.env.CDP_PORT || '9222'; + +const WATCH = `(() => { + if (window.__ytBroadcast) return; window.__ytBroadcast = true; + let adSaved = null; // {muted, rate} captured when an ad starts + const capWant = {}; // videoId -> 'ko' | 'off' (desired, decided once) + const capTries = {}; // videoId -> attempts to read the tracklist + + const adTick = () => { + const p = document.getElementById('movie_player'); + const adShowing = !!(p && p.classList && p.classList.contains('ad-showing')); + const v = document.querySelector('video'); + const skip = document.querySelector( + '.ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-skip-ad-button, .ytp-ad-skip-button-container button'); + if (skip) skip.click(); + document.querySelectorAll('.ytp-ad-overlay-close-button, .ytp-ad-overlay-close-container button').forEach((b) => b.click()); + if (adShowing) { + if (adSaved === null && v) adSaved = { muted: v.muted, rate: v.playbackRate }; + if (v) { + v.muted = true; + if (isFinite(v.duration) && v.duration > 0) { try { v.currentTime = v.duration; } catch {} } + v.playbackRate = 16; + } + } else if (adSaved !== null && v) { + // ad finished: restore exactly what the user had before the ad + v.muted = adSaved.muted; + v.playbackRate = adSaved.rate; + adSaved = null; + } + return adShowing; + }; + + const capTick = (adShowing) => { + if (adShowing) return; // don't touch captions while an ad plays + const p = document.getElementById('movie_player'); + if (!p || !p.getOption || !p.getVideoData) return; + const vid = p.getVideoData().video_id; + if (!vid) return; + // Decide the desired state once per video (off, or Korean if offered). + if (capWant[vid] === undefined) { + capTries[vid] = (capTries[vid] || 0) + 1; + let tracks = []; + try { p.loadModule('captions'); tracks = p.getOption('captions', 'tracklist') || []; } catch {} + if (tracks.length) capWant[vid] = tracks.find((t) => /^ko/i.test(t.languageCode || '')) ? 'ko' : 'off'; + else if (capTries[vid] > 16) capWant[vid] = 'off'; // no tracks: keep it off + else return; // tracklist not ready yet + } + // Enforce it every tick so YouTube cannot silently re-enable captions. + let curLc = ''; + try { const c = p.getOption('captions', 'track'); curLc = (c && c.languageCode) || ''; } catch {} + if (capWant[vid] === 'ko') { + if (!/^ko/i.test(curLc)) { + let tracks = []; try { tracks = p.getOption('captions', 'tracklist') || []; } catch {} + const ko = tracks.find((t) => /^ko/i.test(t.languageCode || '')); + if (ko) { try { p.setOption('captions', 'track', { languageCode: ko.languageCode }); } catch {} } + } + } else if (curLc) { // want off but a track is on -> turn it off + try { p.setOption('captions', 'track', {}); } catch {} + try { p.unloadModule('captions'); } catch {} + } + }; + + setInterval(() => { + let adShowing = false; + try { adShowing = adTick(); } catch {} + try { capTick(adShowing); } catch {} + }, 250); +})();`; + +async function arm(page) { + try { await page.addInitScript(WATCH); } catch {} // survives navigations + try { await page.evaluate(WATCH); } catch {} // arm the already-loaded doc +} + +async function session() { + const b = await chromium.connectOverCDP(`http://localhost:${CDP}`); + const ctx = b.contexts()[0]; + for (const p of ctx.pages()) await arm(p); + ctx.on('page', arm); // new tabs + console.log('broadcast-helper armed on', ctx.pages().length, 'tab(s)'); + await new Promise((resolve) => b.on('disconnected', resolve)); +} + +// Reconnect across Chrome restarts so the broadcast stays ad-free. +while (true) { + try { await session(); } catch { /* CDP down */ } + await new Promise((r) => setTimeout(r, 3000)); +} diff --git a/bot/scripts/stream-test/scenario.mjs b/bot/scripts/stream-test/scenario.mjs index 8c8e5c8..d872308 100644 --- a/bot/scripts/stream-test/scenario.mjs +++ b/bot/scripts/stream-test/scenario.mjs @@ -22,6 +22,14 @@ 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]; @@ -43,46 +51,57 @@ async function browserFullscreen(on) { } 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'; - }); +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 +// 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(SEARCH); +await humanType(MV_QUERY || 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); +// 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); } -await sleep(1500); // 5) set 1080p through the real settings menu (gear -> 화질 -> 1080p), verify async function setQuality1080() { @@ -101,7 +120,6 @@ async function setQuality1080() { 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'); @@ -109,15 +127,15 @@ if ((await auto.count().catch(() => 0)) && (await auto.getAttribute('aria-checke await humanHover(page, playerLoc()); await humanClick(page, auto); } -console.log('STEP watch-1080-windowed'); await sleep(20000); +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). 20s. +// 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(20000); +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);