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

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