74dc80fd3bd2a70371fa75218347ddc45a556dde braney Wed Aug 5 07:28:59 2026 -0700 Docent: render a tour at print resolution, and make zoom wait for its redraw refs #37892 scale: k (DOCENT_SCALE=k, make hires [SCALE=3]) renders the same tour with k times the pixels, for figures that have to print -- a screen still is about 120 dpi across a journal column. Nothing is upscaled; each layer that draws is asked for more: * deviceScaleFactor: k with the viewport left at its 1x CSS size, so the page lays out exactly as at 1x -- same line breaks, same jQuery-dialog width, same tooltip placement -- and rasterizes with k times the pixels, * pix x k so the server draws a wider browser image, with textSize stepped up its ladder to match (3x lands on 24) so hgTracks makes the same layout decisions in it: same tick spacing, same room for labels, same packing of features into rows, * zoom: 1/k on the image table, handing that wider image the 1x amount of layout space, so one image pixel falls on one device pixel. A scaled run is stills-only (no mp4), and DOCENT_STILLS names a different parent so a print render lands beside the screen stills instead of over them. Everything hgTracks reports about the image -- map-box coords, mouseOver spans, insideX, a px: drag endpoint -- is in the pixels the SERVER drew, which is not the displayed size once the image is scaled. Those now go through the image's natural-to-displayed ratio (1 at 1x). Without it a named mouseover: lands k times off and pins a different feature. zoom: in|out waited only for #imgTbl, which the buttons never remove (they redraw in place via ajax), so the following step could read the previous window's map boxes and report an item "not found" that was simply not in view yet. It now waits for the window itself to change. Pre-existing, hidden by the dwell that FAST removes -- and every hires run is FAST. An item-not-found error also names the current window and the items that ARE in that row, which is what told the two cases apart. Co-Authored-By: Claude Opus 5 (1M context) diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js index f4402c92bc3..9150f8fb1ff 100755 --- src/hg/utils/docent/docent.js +++ src/hg/utils/docent/docent.js @@ -1,1330 +1,1454 @@ #!/usr/bin/env node /* docent.js SCRIPT.docent.yaml [OUT.mp4] * * Docent -- a language for authoring guided tours of the UCSC Genome Browser. * * Render a hand-authored Docent script into a silent mp4 PLUS a named still PNG at * every `shot:` marker (the figures). One source script -> both outputs, so a figure * is literally a frame of the tour. Reuses the shared Playwright/Chromium in ~/pwrec. * * PLAYWRIGHT_BROWSERS_PATH=~/pwrec/browsers NODE_PATH=~/pwrec/node_modules \ * node docent.js AP1.docent.yaml * * The high-level verbs bake in the quickLift/Convert mechanics (dbSNP composite * params, the hideDefaults-reverts-on-assembly-change bug, target lookup) so the * author writes intent, not selectors. See README.md for the language. * * The surface syntax is YAML so ordinary editors highlight it; the language is the * verb vocabulary layered on top, not the serialization. Scripts are named * .docent.yaml (a bare .docent also works). */ const { chromium } = require('playwright'); const yaml = require('js-yaml'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); // ---------- parse script + config ---------- const SCRIPT = process.argv[2]; if (!SCRIPT) { console.error('usage: node docent.js SCRIPT.docent.yaml [OUT.mp4]'); process.exit(2); } const doc = yaml.load(fs.readFileSync(SCRIPT, 'utf8')) || {}; // Lint: in a YAML flow map a colon needs a trailing space, so `{item:name5568747}` // parses as ONE key "item:name5568747" (value null) and the intended `item:` arg is // silently dropped -- the verb then quietly falls back to a default. Catch that here // (before the long browser run) by flagging any arg key that contains a ':'. (function lintSteps(steps) { let n = 0; const scan = (obj, where) => { if (!obj || typeof obj !== 'object') return; for (const k of Object.keys(obj)) { if (typeof k === 'string' && k.includes(':')) { n++; console.warn(`WARNING ${where}: key "${k}" contains ':' -- a YAML flow map needs a ` + `space after the colon. Did you mean "${k.replace(/:(?=\S)/, ': ')}"? ` + `(this key is being IGNORED, so the verb may fall back to a default)`); } scan(obj[k], where); } }; (steps || []).forEach((s, i) => { if (s && typeof s === 'object') { const v = Object.keys(s)[0]; scan(s[v], `step ${i + 1} (${v})`); } }); if (n) console.warn(`(${n} suspicious key${n > 1 ? 's' : ''} above -- fix the missing space, or the arg is dropped)`); })(doc.steps); const HERE = path.dirname(path.resolve(SCRIPT)); const base = path.basename(SCRIPT).replace(/\.(docent\.)?ya?ml$/i, '').replace(/\.docent$/i, ''); const FIGDIR = path.resolve(HERE, '..'); // figures dir beside the scripts const OUTMP4 = process.argv[3] || doc.mp4 || path.join(FIGDIR, base + '.mp4'); -const STILLDIR = doc.stills ? path.resolve(HERE, doc.stills) : path.join(HERE, 'stills', base); +// Stills go to stills//. DOCENT_STILLS names a different PARENT ("stills.hires"), +// which is how a high-resolution run keeps its figures beside the screen-resolution ones +// instead of overwriting them. +const STILLPARENT = process.env.DOCENT_STILLS; +const STILLDIR = STILLPARENT ? path.resolve(HERE, STILLPARENT, base) + : doc.stills ? path.resolve(HERE, doc.stills) : path.join(HERE, 'stills', base); // `target:` takes a shorthand from this table, a bare `hgwdev-` sandbox name // (expanded below), or a full https://.../cgi-bin URL. Default is genome-test, so a // script that forgets to say where it runs does not silently hit someone's sandbox. const SERVERS = { 'rr': 'https://genome.ucsc.edu/cgi-bin', 'genome-test': 'https://genome-test.gi.ucsc.edu/cgi-bin', 'hgwdev': 'https://hgwdev.gi.ucsc.edu/cgi-bin', 'hgwbeta': 'https://hgwbeta.soe.ucsc.edu/cgi-bin', }; const resolveTarget = t => { if (!t) return SERVERS['genome-test']; if (SERVERS[t]) return SERVERS[t]; if (/^hgwdev-[a-z0-9._-]+$/i.test(t)) return `https://${t}.gi.ucsc.edu/cgi-bin`; // personal sandbox return t; // full URL }; const SERVER = resolveTarget(doc.target).replace(/\/$/, ''); +// SCALE: the same tour rendered at k times the resolution, for figures that have to print. +// Nothing is upscaled -- a still only ever has the pixels it was drawn with -- so each layer +// is asked to draw k times as many while the layout is left alone: +// +// * deviceScaleFactor: k. The viewport keeps its 1x CSS size, so the page lays out exactly +// as at 1x -- same line breaks, same jQuery-dialog width, same tooltip placement -- and +// every bit of it is rasterized with k times the pixels. The retina case, natively. +// * `pix` x k, so the server draws the browser image k times as wide, with `textSize` +// stepped up to match so hgTracks makes the SAME layout decisions in that bigger image: +// same tick spacing, same room for labels, same packing of features into rows. Without +// the font, a wider image is a different picture rather than a bigger one. +// * `zoom: 1/k` on the image table (SCALE_INIT below), handing that k-times-wider image +// back the 1x amount of layout space. One image pixel then falls on exactly one device +// pixel: native resolution, no resampling anywhere in the path. +// +// So the still comes out k times the 1x still in each dimension, showing the same figure -- +// not a variation of it rendered in a bigger window. +const SCALE = Math.max(1, Number(process.env.DOCENT_SCALE || doc.scale || 1)); const [VW, VH] = doc.size || [1000, 760]; -const PIX = doc.pix || 850; +const PIX = Math.round((doc.pix || 850) * SCALE); +// hgTracks offers a fixed ladder of track font sizes (hgTracks/config.c); step to the one +// closest to scaling its 8px default, so rows and labels grow with the image instead of +// staying 8px tall in a 3x-wide picture. +const TEXTSIZE = [6, 8, 10, 12, 14, 18, 24, 34].reduce((a, b) => + Math.abs(b - 8 * SCALE) < Math.abs(a - 8 * SCALE) ? b : a); +// What every hgTracks nav carries: the image width, plus the font to draw it with at scale. +const IMGVARS = `pix=${PIX}` + (SCALE > 1 ? `&textSize=${TEXTSIZE}` : ''); // FAST: iterate on the FIGURES. Everything that exists only for the video is dropped -- // the dwells, the cursor animation, the dropdown theatrics, the screen recording and the // mp4 transcode. The stills are byte-for-byte what a full run produces, and a run costs // roughly a third as long. `fast: true` in the script, DOCENT_FAST=1, or `make FAST=1 BP1`. -const FAST = !!(doc.fast || process.env.DOCENT_FAST); +// A scaled run is a figure run: at 3x the video would be a 3000px-wide recording of a tour +// nobody watches at that size, so the mp4 is skipped and only the stills are produced. Build +// the video from an unscaled run of the same script. +const FAST = !!(doc.fast || process.env.DOCENT_FAST || SCALE > 1); const PACE = FAST ? 0 : Math.round((doc.pace ?? 1.2) * 1000); // dwell after each step const SHOTHOLD = FAST ? 0 : Math.round((doc.shotHold ?? 2.2) * 1000); // extra pause at a shot // ---------- trackDb ---------- // Docent carries NO table of per-track cart variables. Such a table encodes one snapshot // of trackDb and then quietly lies when trackDb changes (this file used to pin // `clinvarMain=dense` for every `clinvar:` step, for instance). Instead ask the server for // the trackDb it is driving -- hubApi /list/tracks -- and derive what a step needs: // // * the containers above a subtrack (composite, view, superTrack) that have to be // turned on with it, and what each of those takes (a superTrack wants show/hide), // * whether trackDb leaves that subtrack UNSELECTED (`parent off`), in which case // the subtrack checkbox `_sel` has to come along, // * which leaf actually draws the pixels for a container name. // // Everything else -- which dropdown to open, which row to hover -- is read off the live // page. Tracks the listing doesn't know (attached hubs, custom tracks, quickLift's own // tracks on the target) fall back to a literal `name=mode`, which is all Docent could // honestly do for them anyway. const TDB_TTL = 24 * 3600 * 1000; // re-fetch a cached listing daily const TDB_CACHE = path.join(os.tmpdir(), `docent-tdb-${SERVER.replace(/[^\w.-]+/g, '_')}`); let tdbPending = null; // db -> Promise, once each function tdbParse(genome) { const idx = new Map(); const add = (name, o) => { const p = String(o.parent || '').trim().split(/\s+/); idx.set(name, { name, parent: p[0] || null, parentState: (p[1] || '').toLowerCase(), // on | off | a visibility | '' vis: o.visibility || null, view: o.view || null, superChild: !!o.superTrack, // member of a superTrack superTrack: false, // set below for containers children: [], }); for (const [k, v] of Object.entries(o)) if (v && typeof v === 'object' && !Array.isArray(v)) add(k, v); }; for (const [k, v] of Object.entries(genome || {})) if (v && typeof v === 'object' && !Array.isArray(v)) add(k, v); // hubApi flattens superTrack members to the top level and never lists the superTrack // itself, so synthesize the container from the `parent` field it points at. for (const n of [...idx.values()]) { if (!n.parent) continue; if (!idx.has(n.parent)) idx.set(n.parent, { name: n.parent, parent: null, parentState: '', vis: null, view: null, superChild: false, superTrack: true, children: [] }); idx.get(n.parent).children.push(n.name); } return idx; } // The cache holds the DERIVED index (a few hundred kB), not hubApi's reply (~30 MB for // hg38): same information for our purposes, ~20x less to read and parse on every run. function tdbFlatten(idx) { return { docentIndex: 1, rows: [...idx.values()].map(n => [n.name, n.parent, n.parentState, n.vis, n.view, n.superChild ? 1 : 0, n.superTrack ? 1 : 0]) }; } function tdbInflate(o) { const idx = new Map(); for (const [name, parent, parentState, vis, view, superChild, superTrack] of o.rows) idx.set(name, { name, parent, parentState, vis, view, superChild: !!superChild, superTrack: !!superTrack, children: [] }); for (const n of idx.values()) if (n.parent && idx.has(n.parent)) idx.get(n.parent).children.push(n.name); return idx; } async function tdbIndex(db) { if (!tdbPending) tdbPending = new Map(); if (tdbPending.has(db)) return tdbPending.get(db); const p = (async () => { const cache = `${TDB_CACHE}-${db}.json`; try { const st = fs.statSync(cache); if (Date.now() - st.mtimeMs < TDB_TTL) { const o = JSON.parse(fs.readFileSync(cache, 'utf8')); return o && o.docentIndex ? tdbInflate(o) : tdbParse(o); } } catch (e) {} const url = `${SERVER}/hubApi/list/tracks?genome=${enc(db)}&trackLeavesOnly=0`; try { const r = await fetch(url); if (!r.ok) throw new Error(`HTTP ${r.status}`); const j = await r.json(); const genome = j[db]; // A hub-supplied genome (an assembly hub, or quickLift's own generated target hub) // isn't in the server's trackDb listing at all -- expected, not a failure. if (!genome) { console.log(`trackDb: ${db} is not a server assembly (hub genome), ` + `so track steps there are sent as literal name=mode`); return null; } const idx = tdbParse(genome); // Write via a unique temp file + rename so parallel builds (make -j) can't read a // half-written cache. try { const tmp = `${cache}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(tdbFlatten(idx))); fs.renameSync(tmp, cache); } catch (e) {} console.log(`trackDb: ${idx.size} tracks for ${db} from ${SERVER}/hubApi`); return idx; } catch (e) { console.warn(`trackDb: could not read ${url} (${e.message}) -- ` + `falling back to literal name=mode for every track step`); return null; } })(); tdbPending.set(db, p); return p; } const sleep = ms => new Promise(r => setTimeout(r, ms)); // A pause that exists only so a viewer can follow the video: skipped entirely in FAST. const dwell = ms => (FAST ? Promise.resolve() : sleep(ms)); // Typing is shown on screen for the video; in FAST just put the text in the box (fill() // still fires the input events the autocompletes listen for). async function typeIn(pg, sel, text) { if (FAST) await pg.fill(sel, String(text)); else await pg.type(sel, String(text), { delay: 45 }); } const enc = s => encodeURIComponent(String(s)); const state = { db: doc.db || 'hg38', position: doc.position || '', hgsid: '' }; +// ---------- SCALE: give the k-times-wider browser image the 1x amount of layout space ---------- +// hgTracks sizes the image table in the HTML it writes, so shrinking the elements alone +// would leave the table 3x wider than its own contents. Zoom the table: the zoom reaches the +// images inside it, and its box goes back to the width the 1x page gives it -- 854 CSS px for +// a pix=850 run, whatever k is -- so the rest of the page is laid out exactly as at 1x while +// the image keeps all k times its pixels (one per device pixel at deviceScaleFactor: k). +// +// Installed for the whole run rather than at the shutter: the tour's own geometry -- a drag +// across the image, a mouseover on a feature -- then works in the same coordinates a 1x run +// works in, and needs no scale arithmetic of its own. +const SCALE_INIT = (k) => { + const add = () => { + if (document.getElementById('__scale')) return; + const s = document.createElement('style'); + s.id = '__scale'; + s.textContent = `#imgTbl, #chromIdeoImg, img[src*="hgtIdeo"] { zoom: ${1 / k} !important; }`; + (document.head || document.documentElement).appendChild(s); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add); + else add(); +}; + // ---------- animated cursor (same technique as the walkthrough-video skill's record.js) ---------- const CURSOR_INIT = () => { const add = () => { if (document.getElementById('__cur')) return; const c = document.createElement('div'); c.id = '__cur'; c.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;width:24px;height:24px;margin-left:-3px;margin-top:-2px;filter:drop-shadow(0 1px 1px rgba(0,0,0,.4));'; c.innerHTML = ''; document.documentElement.appendChild(c); const place = (x, y) => { c.style.transform = `translate(${x}px,${y}px)`; }; place(120, 120); document.addEventListener('mousemove', e => place(e.clientX, e.clientY), true); document.addEventListener('mousedown', e => { const r = document.createElement('div'); r.className = '__ripple'; r.style.cssText = `position:fixed;left:${e.clientX}px;top:${e.clientY}px;z-index:2147483645;pointer-events:none;width:6px;height:6px;margin:-3px 0 0 -3px;border:3px solid rgba(225,30,30,.95);border-radius:50%;`; document.documentElement.appendChild(r); r.animate([{ transform: 'scale(1)', opacity: 1 }, { transform: 'scale(6)', opacity: 0 }], { duration: 520, easing: 'ease-out' }); setTimeout(() => r.remove(), 540); }, true); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add); else add(); }; function absurl(u) { if (/^https?:/.test(u)) return u; if (u.startsWith('/cgi-bin/')) return SERVER.replace(/\/cgi-bin$/, '') + u; if (u.startsWith('/')) return SERVER.replace(/\/cgi-bin$/, '') + u; return SERVER + '/' + u; } const T_START = Date.now(); (async () => { 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: 1, + 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); await ctx.addInitScript(CURSOR_INIT); await ctx.addInitScript(() => { try { localStorage.setItem('hgTracks_hideTutorial', '1'); } catch (e) {} }); const page = await ctx.newPage(); 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(() => { try { if (typeof hgTracks !== 'undefined' && hgTracks.chromName) return hgTracks.chromName + ':' + (hgTracks.winStart + 1) + '-' + hgTracks.winEnd; } catch (_) {} return null; }); if (pos) state.position = pos; } catch (e) {} // Authoring aid: DOCENT_ROWS=1 logs the rows hgTracks actually drew, so "why is that // subtrack still there / why is my mouseover track not shown" is one run, not a guess. if (process.env.DOCENT_ROWS) { const rows = await page.evaluate(() => [...document.querySelectorAll('[id^="img_data_"]')].map(e => e.id.replace('img_data_', ''))).catch(() => null); if (rows) console.log(' rows:', rows.join(', ') || '(none)'); } } async function nav(u) { pinnedTips.length = 0; await page.goto(absurl(u), { waitUntil: 'load' }); await captureState(); await page.mouse.move(cur.x, cur.y); } async function glide(x, y) { if (FAST) { await page.mouse.move(x, y); cur.x = x; cur.y = y; return; } const steps = Math.max(10, Math.round(Math.hypot(x - cur.x, y - cur.y) / 9)); for (let i = 1; i <= steps; i++) { await page.mouse.move(cur.x + (x - cur.x) * i / steps, cur.y + (y - cur.y) * i / steps); await sleep(15); } cur.x = x; cur.y = y; } async function glideTo(sel) { const b = await page.locator(sel).first().boundingBox({ timeout: 8000 }).catch(() => null); if (b) await glide(b.x + b.width / 2, b.y + b.height / 2); } async function clickGlide(sel) { await glideTo(sel); await sleep(160); await page.click(sel); } async function checkGlide(sel, want) { await glideTo(sel); await sleep(140); if (want) await page.check(sel).catch(() => {}); else await page.uncheck(sel).catch(() => {}); } // visibly open a native