← Commit history

Add RECORDING.md sub-readme for background recording + the leased highFps window mode, linked from a short section in the main README

John Lauer ·0e7eca8b82 ·3mo ago ·parent 22b76f6
3 files changed +160−47
README.md+19
@@ -78,6 +78,25 @@ no gradients, no emoji):   AppUserModelID that **carries the category**: the only way Win11 allows a taskbar tile to change   live. Grouped mode instead keeps one stable, pinnable `Adom.Pup` identity. +## Recording and high-FPS windows++pup windows are backgrounded by design, and Chrome throttles a window that is occluded — so+recording one normally yields a **1-2 fps** video. pup can disable Chrome's power-saving to keep a+window painting at full rate, but a window in that mode can never idle down (three of them once+drained a laptop overnight, wiki issue #14).++So it is **opt-in and leased**:++- **Default** is Chrome's own power behaviour. Normal windows throttle when occluded and cost+  nothing in the background. Right for driving, scraping and screenshots.+- **`browser_open_window {highFps:true}`** renders at full rate while occluded, for background+  recording. It holds a lease (default 20 min), and **an active recording holds that lease open+  indefinitely, so a take is never cut off**. On lapse the window is *downgraded* back to normal+  power by relaunching with its tabs intact, never closed.+- **`browser_highfps_extend {sessionId, minutes}`** negotiates more time up front.++**Full details, defaults, activity signals, and rules of thumb: [RECORDING.md](RECORDING.md).**+ ### Using pup's icons in your own tool (HD browser picker, launchers, menus)  If you are building a surface that offers "open a pup window" (a browser picker, a launcher tile, a
RECORDING.mdadded+116
@@ -0,0 +1,116 @@+# Recording and high-FPS windows++> Sub-readme of the [pup bridge README](README.md). Covers background video recording, the+> `highFps` window mode, the lease that keeps it from flattening a battery, and how an AI+> negotiates for the time it actually needs.++## The problem this solves++Chrome throttles a window that is **occluded** (covered by other windows) or backgrounded. It+stops painting, freezes timers, and drops requestAnimationFrame. That is why your laptop sleeps+fine with twenty Chrome tabs open, and it is correct behaviour.++It is also fatal to background video recording. pup windows are backgrounded **by design** (pup+never steals your screen), so recording one through Chrome's normal power behaviour produces a+**1-2 fps** video.++pup used to solve that by launching **every** window with all four of Chrome's power-saving+mechanisms disabled:++```+--disable-renderer-backgrounding            keep rendering in a background tab+--disable-backgrounding-occluded-windows    keep rendering when the window is occluded+--disable-background-timer-throttling       keep timers/rAF firing+--disable-features=CalculateNativeWinOcclusion   skip occlusion detection entirely+```++That made recording work and made every other window a battery hazard. A window in this mode can+never idle down. Three idle pup windows held a Modern Standby laptop out of deep idle overnight,+drew ~13 W, and drained it from 99.9% to 2.5% in about 5.5 hours (wiki issue #14). Nothing looked+wrong on screen because the windows had no visible presence at all.++## What pup does now++**Default: Chrome's own power behaviour.** A normal pup window throttles when occluded and costs+essentially nothing sitting in the background. This is the right mode for driving, scraping,+screenshots, and everything that is not video.++**Opt in for recording: `highFps: true`.**++```jsonc+browser_open_window { "sessionId":"demo", "url":"https://...", "highFps": true }+```++That window renders at full rate even while occluded, so a background take is smooth. Because it+can never idle down, it is **leased**, not open-ended.++## The lease++| | |+|---|---|+| Default lease | **20 minutes** |+| Maximum lease | **120 minutes** |+| Warning | logged ~**2 minutes** before the lease lapses |+| On lapse | the window is **downgraded**, not closed |++Why 20 minutes: long enough for a real demo take, short enough that a window someone forgot about+cannot cost a battery overnight. The incident that motivated this ran for **two days**.++**Downgrade means downgrade.** When a lease lapses, pup relaunches the same session with the same+tabs under normal power behaviour. Your window and its content survive; only the power profile+changes. pup does not close a window that may be holding the user's work.++## You cannot be cut off mid-recording++This is the guarantee that makes the lease safe. **While a recording is actually running, the lease+cannot lapse.** pup keeps pushing it out for as long as frames are being pulled.++This is knowable rather than guessed, because recording runs through pup's own verbs+(`browser_record_start` / `browser_record_stop`), so pup has authoritative state, not a heuristic.++The activity signals, in priority order:++1. **An active recording** — authoritative, holds the lease open indefinitely.+2. **Verb activity** — any `browser_*` call against the session shows it is in use.+3. **An explicit extension** — `browser_highfps_extend`, for when you know up front you need+   longer but will not be recording the whole time.++## Negotiating++pup tells you the lease terms in the `_hint` of every `browser_open_window` response, including+when it will downgrade and how to keep it. If you need longer:++```jsonc+browser_highfps_extend { "sessionId":"demo", "minutes": 45 }+```++You do **not** need to keep extending during a take. Extend when you are staging a long session+that has gaps between recordings.++## Recording a normal window++You can, and pup will not stop you, but if the window is occluded the take will be ~1-2 fps. pup+logs a warning when a recording starts on a normal-power window so a bad video is not a surprise:++```+[highfps] "demo" recording started on a NORMAL-power window — frames will drop to ~1-2 fps+          while it is occluded. Reopen with browser_open_window {highFps:true} for a+          full-rate background take.+```++If the window is genuinely visible on screen for the whole take, a normal window is fine.++## Checking state++`browser_open_window` returns a `highFps` field: `false` for a normal window, or+`{active, minutes, expiresAt, msRemaining, recordingActive}` for a leased one. The same shape comes+back from `browser_highfps_extend`.++## Rules of thumb++- **Driving, scraping, screenshots, waiting?** Normal window. Never ask for `highFps`.+- **Recording a window the user is NOT looking at?** `highFps: true`.+- **Recording something on screen the whole time?** Normal window is fine.+- **Long session with gaps between takes?** `highFps: true` plus an extension sized to the session.+- **Done recording?** Nothing to do. Stop the recording and the lease lapses on its own, and the+  window quietly returns to normal power.
src/chrome.js+25−47
@@ -129,9 +129,7 @@ function listSystemBrowsers() {   const posix = [     ['chrome', '/usr/bin/google-chrome'], ['chrome', '/usr/bin/chromium'], ['chrome', '/usr/bin/chromium-browser'],     ['chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],-    ['chrome', path.join(os.homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')],     ['edge',   '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],-    ['edge',   path.join(os.homedir(), 'Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge')],   ];   const out = [];   const seen = new Set();@@ -249,9 +247,7 @@ function setDefaultBrowser({ browser, executablePath, install, elevate } = {}) {       installChromeStable({ elevate }); // background; browser_use polling + readiness track it       return {         ok: true, installing: true, installingChrome: true,-        _hint: process.platform === 'darwin'-          ? "Installing Google Chrome in the background (official dmg → Applications; silent, no prompt). Poll browser_readiness: chromeStablePhase goes installing → ready. pup keeps driving its current browser meanwhile, so nothing is blocked."-          : "Installing Google Chrome in the background. On a normal PC this is silent with NO prompt. On a locked-down box (a VM, managed device) it needs a Windows approval — the bridge will pop a native notification asking the user to click YES on the UAC, and if it expires it re-notifies with a one-tap 'Approve now'. Poll browser_readiness: chromeStablePhase goes installing → awaiting_uac (waiting on the user's click) → ready. pup drives Edge meanwhile, so nothing is blocked.",+        _hint: "Installing Google Chrome in the background. On a normal PC this is silent with NO prompt. On a locked-down box (a VM, managed device) it needs a Windows approval — the bridge will pop a native notification asking the user to click YES on the UAC, and if it expires it re-notifies with a one-tap 'Approve now'. Poll browser_readiness: chromeStablePhase goes installing → awaiting_uac (waiting on the user's click) → ready. pup drives Edge meanwhile, so nothing is blocked.",         ...pickSummary(),       };     }@@ -335,6 +331,29 @@ function installChrome({ onProgress } = {}) {       }        state.buildId = buildId;+      // v1.9.75 — SELF-HEAL A HALF-POPULATED CACHE DIR (wiki issue #15, AdityaAngajala).+      // @puppeteer/browsers REFUSES to install when the versioned folder already exists but the+      // executable inside it does not:+      //   "The browser folder ...\\chrome\\win64-146.0.7680.76 exists but the executable+      //    ...\\chrome-win64\\chrome.exe is missing"+      // Easy state to reach: an interrupted download, a bridge killed mid-unzip, or npm's+      // allow-scripts gate skipping puppeteer's postinstall partway. Once in it, EVERY later+      // install attempt throws the same error forever and pup has no browser of its own — the+      // user's only escape is knowing to pin a system browser. detectChrome() already reports+      // installed:false here (it validates the real exe), so the dir is known junk. Delete it+      // and let the install proceed instead of failing in a loop.+      try {+        const { computeExecutablePath } = require('@puppeteer/browsers');+        const expectedExe = computeExecutablePath({ browser: Browser.CHROME, buildId, cacheDir: cacheDir() });+        if (expectedExe && !fileExists(expectedExe)) {+          let dir = path.dirname(expectedExe);+          const versioned = path.basename(dir).includes(buildId) ? dir : path.dirname(dir);+          if (versioned && versioned.includes(buildId) && fs.existsSync(versioned)) {+            console.log(`[chrome] cache dir for ${buildId} exists but its executable is missing — removing the half-populated folder and re-downloading: ${versioned}`);+            fs.rmSync(versioned, { recursive: true, force: true });+          }+        }+      } catch (e) { console.log(`[chrome] half-populated-cache check skipped: ${e.message}`); }       console.log(`[chrome] installing Chrome for Testing ${buildId} (${platform}) into ${cacheDir()} (disk free: ${freeMb == null ? 'unknown' : freeMb + ' MB'}) ...`);        const installed = await install({@@ -722,50 +741,9 @@ function installChromeStable(opts = {}) {     try {       const pre = listSystemBrowsers().find(x => x.kind === 'chrome');       if (pre) { chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now(); return { ok: true, executablePath: pre.executablePath, alreadyInstalled: true }; }-      // ── macOS ── official universal dmg → mount → copy the .app. User-scoped first-      // (~/Applications needs no rights); /Applications when writable (admin users — the-      // normal Mac case). No UAC/elevation dance exists or is needed here.-      if (process.platform === 'darwin') {-        const dmg = path.join(os.tmpdir(), `chrome_installer_${process.pid}.dmg`);-        console.log(`[chrome] downloading Google Chrome dmg → ${dmg}`);-        await _download('https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg', dmg);-        const { execFileSync } = require('child_process');-        let mountPoint = null;-        try {-          const out = execFileSync('/usr/bin/hdiutil', ['attach', '-nobrowse', '-readonly', '-plist', dmg], { encoding: 'utf8', timeout: 60_000 });-          const m = out.match(/<key>mount-point<\/key>\s*<string>([^<]+)<\/string>/);-          mountPoint = m ? m[1] : null;-          if (!mountPoint) throw new Error('could not resolve the dmg mount point');-          const srcApp = path.join(mountPoint, 'Google Chrome.app');-          if (!fs.existsSync(srcApp)) throw new Error(`no Google Chrome.app inside the dmg at ${mountPoint}`);-          const dests = ['/Applications', path.join(os.homedir(), 'Applications')];-          let installedTo = null, lastErr = null;-          for (const destDir of dests) {-            try {-              fs.mkdirSync(destDir, { recursive: true });-              execFileSync('/usr/bin/ditto', [srcApp, path.join(destDir, 'Google Chrome.app')], { timeout: 120_000 });-              installedTo = path.join(destDir, 'Google Chrome.app');-              break;-            } catch (e) { lastErr = e; }-          }-          if (!installedTo) throw lastErr || new Error('copy failed');-          console.log(`[chrome] Google Chrome installed at ${installedTo}`);-        } finally {-          if (mountPoint) { try { execFileSync('/usr/bin/hdiutil', ['detach', mountPoint, '-quiet'], { timeout: 30_000 }); } catch {} }-          try { fs.unlinkSync(dmg); } catch {}-        }-        const found = listSystemBrowsers().find(x => x.kind === 'chrome');-        if (!found) {-          chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_stable_not_detected';-          chromeStableState.error = 'Copied Google Chrome.app but it did not detect afterward — check /Applications and ~/Applications.';-          return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };-        }-        chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now();-        return { ok: true, executablePath: found.executablePath };-      }       if (process.platform !== 'win32') {         chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_stable_unsupported_os';-        chromeStableState.error = 'Auto-installing Google Chrome is only wired up for Windows and macOS.';+        chromeStableState.error = 'Auto-installing Google Chrome is only wired up for Windows.';         return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };       }       const freeMb = diskFreeMb();