7603e0192dd9fdf257f0a57b500d4e936cd42e93 braney Sat Sep 19 09:45:41 2026 -0700 docent: drop the one-frame screenshot artifact from a recorded tour, refs #37892 #38364 A shot: taken while the video is recording can leave one bad frame in the mp4, the track image painted on a grey, unpainted page, because Playwright's screenshot captures from the same surface the recorder reads. It is a race: four renders of one script gave 2, 1, 1 and 0 of them, and a sweep of the 46 finished tours under ~/docentTours found the frame in 24, 33 frames in all. Capturing through CDP instead does keep the recorder out of it, but Chromium then ignores the clip and answers with the whole viewport, so every still would need cropping and a taller-than-viewport image could not be captured at all. Preferring page.screenshot({clip}) over an element screenshot does not help either, and shifts the still by a pixel in each dimension. Both measured. So the frame is dropped in the transcode that was going to happen anyway: the time of each shot is remembered, the webm is scanned once for a frame more than 20 luma darker than both its neighbours, and only a dip within half a second of a shot is cut. One encode, stills untouched, and a tour with no artifact runs exactly the command it ran before. The shot-time gate is what makes it safe -- an ordinary dark frame in a tour is never next to a capture. Measured: the #38364 tour twice, found and dropped at 3.68s and 3.72s, mp4 clean afterwards, and its three stills byte-identical to the stock renderer's. FAST mode is untouched, since it returns before the transcode. diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js index e47fe591f47..819389c47e5 100755 --- src/hg/utils/docent/docent.js +++ src/hg/utils/docent/docent.js @@ -360,30 +360,32 @@ const T_START = Date.now(); (async () => { // Before the browser: nothing in the derivation touches the page, and the point is to // not pay for one. (The helpers below are function declarations, so they are hoisted.) if (DERIVE) { await deriveMain(); return; } fs.mkdirSync(STILLDIR, { recursive: true }); const browser = await chromium.launch({ headless: true, args: ['--force-color-profile=srgb'] }); const ctx = await browser.newContext({ viewport: { width: VW, height: VH }, deviceScaleFactor: SCALE, ...(FAST ? {} : { recordVideo: { dir: path.join(HERE, '.vid_' + base), size: { width: VW, height: VH } } }), }); if (SCALE > 1) await ctx.addInitScript(SCALE_INIT, SCALE_ARGS); await ctx.addInitScript(CURSOR_INIT, { box: CURSOR_BOX, svg: CURSOR_SVG }); await ctx.addInitScript(() => { try { localStorage.setItem('hgTracks_hideTutorial', '1'); } catch (e) {} }); const page = await ctx.newPage(); + const T_REC = Date.now(); // the recorder starts with the page; see FLASH below + const shotSecs = []; // when each shot: was taken, seconds into the recording const cur = { x: 120, y: 120 }; const pinnedTips = []; // recorded mouseover tooltips for the next pinShot (per view) async function captureState() { try { const u = new URL(page.url()); const h = u.searchParams.get('hgsid'); if (h) state.hgsid = h; const db = u.searchParams.get('db'); if (db) state.db = db; const p = u.searchParams.get('position'); if (p) state.position = p; } catch (e) {} // An interactive zoom / drag-select reload stores the new window in the CART, not the // URL, so read the live position straight from hgTracks when we're on a tracks page -- // otherwise a later position-based nav (e.g. turning on a track) reverts the zoom. try { const pos = await page.evaluate(() => { @@ -667,30 +669,31 @@ const a = im.getBoundingClientRect(); let x = a.left, y = a.top, x2 = a.right, y2 = a.bottom; for (const o of overlays) { const r = o.getBoundingClientRect(); x = Math.min(x, r.left); y = Math.min(y, r.top); x2 = Math.max(x2, r.right); y2 = Math.max(y2, r.bottom); } return { x: Math.max(0, x - 4), y: Math.max(0, y - 4), width: (x2 - x) + 8, height: (y2 - y) + 8 }; }); if (clip) { await page.screenshot({ path: p, clip }); } else if (await page.locator('#imgTbl').count()) { await page.locator('#imgTbl').screenshot({ path: p }); } else { // Not a tracks page (hgc detail page, an external page a link led to, ...). An element // shot of
would run the whole scrolling page -- thousands of pixels tall and // unusable as a figure. Capture the viewport only, i.e. the top of the page. await page.screenshot({ path: p }); } + shotSecs.push((Date.now() - T_REC) / 1000); console.log('SHOT', p); await sleep(SHOTHOLD); } // Resolve a track name to its DOM key + data-image and row bounding boxes. On a // quickLift/Convert target the tracks come from a hub, so ids gain a dynamic // `hub__` prefix -- match by suffix so the YAML can just say `track: quickLiftChain`. // A container draws nothing itself, so if the name given is one, fall through to the // leaves trackDb lists under it and take the first that is actually on the page. async function trackBox(t) { const cands = [t, ...await tdbLeaves(t)]; let key = null; for (const c of cands) { key = await page.evaluate(k => { if (document.getElementById('img_data_' + k)) return k; const el = [...document.querySelectorAll('[id^="img_data_"]')] @@ -2413,21 +2416,57 @@ const tot = timing.reduce((a, t) => a + t.ms, 0); console.log(`--- steps: ${(tot / 1000).toFixed(1)}s total`); for (const t of [...timing].sort((a, b) => b.ms - a.ms)) console.log(` ${(t.ms / 1000).toFixed(1)}s step ${t.n} ${t.verb}`); } if (FAST) { console.log('DONE (fast: stills only, no mp4) -> stills in', STILLDIR, `| ${((Date.now() - T_START) / 1000).toFixed(0)}s`); return; } const tVid = Date.now(); // transcode webm -> silent mp4 const vdir = path.join(HERE, '.vid_' + base); const webm = fs.readdirSync(vdir).filter(f => f.endsWith('.webm')).map(f => path.join(vdir, f)).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0]; const FF = execFileSync('python3', ['-c', 'import imageio_ffmpeg,sys;sys.stdout.write(imageio_ffmpeg.get_ffmpeg_exe())']).toString().trim(); - execFileSync(FF, ['-y', '-loglevel', 'error', '-i', webm, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '22', '-preset', 'veryfast', '-movflags', '+faststart', OUTMP4]); + // THE CAPTURE ARTIFACT, and why the transcode is where it is dealt with. + // + // Playwright's screenshot captures from the same surface the video recorder reads, so a + // `shot:` taken while recording can leave ONE bad frame in the video: the track image + // painted on a grey, unpainted page. It is a race -- a re-render may or may not carry + // it -- and a sweep of 46 finished tours found it in 24. Proved by rendering a tour with + // every `shot:` removed, which is always clean. + // + // It cannot be fixed at the capture. CDP's Page.captureScreenshot with fromSurface:false + // does keep the recorder out of it, but Chromium then IGNORES the clip and answers with + // the whole viewport, so every still would need cropping and a taller-than-viewport + // image could not be captured at all. Measured, not assumed. + // + // So the frame is dropped here instead, in the one encode that was going to happen + // anyway. Nothing is re-encoded twice and the stills are untouched. The search is + // narrowed to the moments a shot was actually taken (shotSecs), so an ordinary dark + // frame in the tour itself is never a candidate. Refs #37892, found on #38364. + const FPS = 25, DIP = 20, NEAR = 0.5; + let vfilter = null; + if (shotSecs.length) { + const stats = execFileSync(FF, ['-loglevel', 'error', '-i', webm, '-vf', + `fps=${FPS},scale=160:-1,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-`, + '-f', 'null', '-'], { maxBuffer: 1 << 28 }).toString(); + const y = [...stats.matchAll(/YAVG=([0-9.]+)/g)].map(m => +m[1]); + const bad = []; + for (let i = 1; i < y.length - 1; i++) + if (y[i] < y[i - 1] - DIP && y[i] < y[i + 1] - DIP && + shotSecs.some(t => Math.abs(t - i / FPS) < NEAR)) bad.push(i); + if (bad.length) { + vfilter = `fps=${FPS},select='not(${bad.map(i => `eq(n\,${i})`).join('+')})',setpts=N/${FPS}/TB`; + console.log(` (dropped ${bad.length} capture-artifact frame(s) at ` + + bad.map(i => (i / FPS).toFixed(2) + 's').join(', ') + ')'); + } + } + execFileSync(FF, ['-y', '-loglevel', 'error', '-i', webm, + ...(vfilter ? ['-vf', vfilter, '-r', String(FPS)] : []), + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '22', '-preset', 'veryfast', '-movflags', '+faststart', OUTMP4]); fs.rmSync(vdir, { recursive: true, force: true }); if (process.env.DOCENT_TIME) console.log(`--- mp4 transcode: ${((Date.now() - tVid) / 1000).toFixed(1)}s`); console.log('DONE ->', OUTMP4, '| stills in', STILLDIR, `| ${((Date.now() - T_START) / 1000).toFixed(0)}s`); })().catch(e => { console.error(e); process.exit(1); });