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>
This commit is contained in:
javis-bot
2026-06-10 16:09:31 +09:00
parent 0241628fed
commit f93b241575
4 changed files with 187 additions and 48 deletions

View File

@@ -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?"

View File

@@ -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));
}

View File

@@ -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);