4bd85aa14161039a40fd499bd8416ab51f099d81 braney Sat Jul 25 18:34:48 2026 -0700 add Docent, a language for authoring Genome Browser tours refs #37892 A Docent script is an ordered list of high-level verbs (go, hide, track, mouseover, convert, drag, shot) that drives a real browser against a real server, and renders to a silent mp4 plus a named PNG still at every shot marker. One source, both outputs, so a published figure is a frame of the tour and the two cannot drift apart. The verbs encode browser mechanics rather than selectors. convert: knows the Hide-defaults checkbox reverts when the Assembly menu reloads, track: {clinvar: pack} knows that composite's clean parameter set, and mouseover: knows a lifted track's DOM id gains a per-run hub__ prefix. The author writes intent. The surface syntax is YAML so ordinary editors highlight it; the language is the verb vocabulary on top, not the serialization. Scripts are named .docent.yaml. This was built inside the quickLift Current Protocols paper repo, where it still has its six working scripts, and only the general parts move here: the renderer, the language reference, and the make rules a project includes to get incremental rebuilds. Nothing in the build refers to the directory, the same way cartTrackVarCatalog does not, since these are node scripts and need playwright and js-yaml. target: defaults to genome-test rather than the sandbox it grew up on, and resolves any hgwdev- name, so a script that forgets to say where it runs does not quietly hit somebody's machine. Next step is #37838. Today a step reaches the browser as CGI variables, which is why the verbs have to know each composite by heart; once the cart takes a structured blob a step can name the state it wants instead. Co-Authored-By: Claude Opus 5 (1M context) diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js new file mode 100755 index 00000000000..e07bf093564 --- /dev/null +++ src/hg/utils/docent/docent.js @@ -0,0 +1,758 @@ +#!/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 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); + +// `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(/\/$/, ''); +const [VW, VH] = doc.size || [1000, 760]; +const PIX = doc.pix || 850; +const PACE = Math.round((doc.pace ?? 1.2) * 1000); // dwell after each step +const SHOTHOLD = Math.round((doc.shotHold ?? 2.2) * 1000); // extra dwell (pause) at a shot + +// track-name -> cart params (composite tracks expand to their learned "clean" config) +const TRACKS = { + mane: m => [`mane=${m}`], + dbSnp155: m => [`dbSnp155Composite=${m}`, `dbSnp155Common=${m}`, `dbSnp155ViewVariants=${m}`, `dbSnp155ViewErrs=hide`], + clinvar: m => [`clinvar=${m}`, `clinvarMain=dense`, `clinvarSubLolly=${m}`, `clinvarCnv=hide`], +}; +// The control-dropdown name and the data-image id can differ from the shortcut name: +// composites are turned on via their composite cart var, and dbSNP's visible pixels +// live in the "Common" subtrack's data image. +const CTRL = { dbSnp155: 'dbSnp155Composite' }; // select[name=...] in the track controls +const DATAIMG = { dbSnp155: 'dbSnp155Common', clinvar: 'clinvarMain' }; // #img_data_... that holds the drawn items +const ctrlName = t => CTRL[t] || t; +const imgTrack = t => DATAIMG[t] || t; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const enc = s => encodeURIComponent(String(s)); +const state = { db: doc.db || 'hg38', position: doc.position || '', hgsid: '' }; + +// ---------- 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.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; +} + +(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, + recordVideo: { dir: path.join(HERE, '.vid_' + base), size: { width: VW, height: VH } }, + }); + 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) {} + } + 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) { + 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