~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
259 lines · 10.8 KB Raw History
// Someone at the pages, filmed: a browser logged in as them, Chrome's own
// screencast saving each frame it paints, and a cursor and keyboard moving
// at a person's pace. A take is a folder of frames under public/footage
// with a take.json of when each was painted, the marks the scene set, and
// where each click landed, which the video plays back (video/Footage.tsx).

import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright-core';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');

export const SIZES = {
	wide: { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1.5 },
	phone: {
		viewport: { width: 390, height: 844 },
		deviceScaleFactor: 3,
		isMobile: true,
		hasTouch: true
	}
};

/// A browser for the world: its gateway's name resolved here, its
/// certificate taken, drawn dark as the videos are.
export async function launch(size) {
	const browser = await chromium.launch({
		headless: true,
		// Without it a headless screencast sends frames at 1x, whatever the
		// context's deviceScaleFactor.
		args: [
			`--force-device-scale-factor=${SIZES[size].deviceScaleFactor}`,
			'--host-resolver-rules=MAP gateway.knotless.internal 127.0.0.1'
		]
	});
	const context = await browser.newContext({
		...SIZES[size],
		ignoreHTTPSErrors: true,
		colorScheme: 'dark',
		locale: 'en-US'
	});
	return { browser, context };
}

/// Until the page's script has taken it over, as the root says once mounted.
export const ready = (page) => page.locator('html[data-ready]').waitFor({ state: 'attached' });

/// Logs `who` in from the header's dialog, through their PDS's page: done
/// before filming, so the take starts logged in.
export async function logIn(page, world, who) {
	await page.getByRole('button', { name: 'Log in' }).click();
	const dialog = page.getByRole('dialog', { name: 'Log in with your Atmosphere account' });
	await dialog.getByLabel('Handle').fill(who.handle);
	const back = page.url();
	await dialog.getByRole('button', { name: 'Log in' }).click();
	await page.waitForURL((u) => u.origin === new URL(world.pds).origin);
	await page.getByRole('textbox', { name: 'Password' }).fill(who.password);
	await page.getByRole('button', { name: 'Sign in' }).click();
	await page.getByRole('button', { name: /^(accept|authorize|allow)$/i }).click();
	await page.waitForURL(back);
	await ready(page);
	await page.getByLabel('Your account').waitFor();
}

/// Film `page` as `take`, at `size`: frames into public/footage/<take>.
export async function film(take, page, size) {
	const dir = join(root, 'public/footage', take);
	rmSync(dir, { recursive: true, force: true });
	mkdirSync(dir, { recursive: true });
	await page.addInitScript(CURSOR);
	await page.addInitScript(`(${LIVE})()`);
	// A phone is touched, not pointed at: no cursor, just the taps the video draws.
	if (size === 'phone') await page.addInitScript(HIDE_CURSOR);

	const { width, height } = SIZES[size].viewport;
	const scale = SIZES[size].deviceScaleFactor;
	const cdp = await page.context().newCDPSession(page);
	const frames = [];
	const marks = {};
	const clicks = [];
	let t0 = 0;
	let n = 0;
	// Written as they come: a sync write here held up the scene's own moves.
	const writing = [];
	cdp.on('Page.screencastFrame', (e) => {
		void cdp.send('Page.screencastFrameAck', { sessionId: e.sessionId }).catch(() => {});
		const t = e.metadata.timestamp ?? Date.now() / 1000;
		if (!t0) t0 = t;
		const f = `${String(n++).padStart(5, '0')}.jpg`;
		frames.push({ t: +(t - t0).toFixed(3), f });
		writing.push(writeFile(join(dir, f), Buffer.from(e.data, 'base64')));
	});
	await cdp.send('Page.startScreencast', {
		format: 'jpeg',
		quality: 92,
		maxWidth: width * scale,
		maxHeight: height * scale,
		everyNthFrame: 1
	});
	const now = () => (t0 ? Date.now() / 1000 - t0 : 0);
	const at = { x: width / 2, y: height / 2 };
	const cursor = (x, y, hand) =>
		page.evaluate(([cx, cy, h]) => window.__cursor?.(cx, cy, h), [x, y, hand]).catch(() => {});

	/// To `target`, the cursor a hand as it nears it when it is to be clicked.
	async function moveTo(target, hand = false) {
		await target.scrollIntoViewIfNeeded().catch(() => {});
		const box = await target.boundingBox();
		if (!box) throw new Error(`nothing to point at: ${target}`);
		const x = box.x + Math.min(box.width / 2, 40 + Math.random() * 10);
		const y = box.y + box.height / 2;
		const steps = 24;
		for (let i = 1; i <= steps; i++) {
			const k = i / steps;
			const e = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
			const px = at.x + (x - at.x) * e;
			const py = at.y + (y - at.y) * e;
			await page.mouse.move(px, py);
			await cursor(px, py, hand && k > 0.6);
			await page.waitForTimeout(16);
		}
		at.x = x;
		at.y = y;
	}
	const press = async () => {
		clicks.push({ t: +now().toFixed(3), x: Math.round(at.x), y: Math.round(at.y) });
		await page.evaluate(() => window.__press?.()).catch(() => {});
	};

	return {
		page,
		/// Go to `url`, and wait for the page to be ready.
		open: async (url) => {
			await page.goto(url);
			await ready(page);
			await cursor(at.x, at.y, false);
		},
		/// Note where the scene is, for the edit to cut on.
		mark: (name) => {
			marks[name] = +now().toFixed(3);
		},
		/// Move the cursor to something, without clicking.
		point: (target) => moveTo(target),
		click: async (target, { pause = 180 } = {}) => {
			await moveTo(target, true);
			await page.waitForTimeout(pause);
			await press();
			await target.click();
			await cursor(at.x, at.y, false);
			await page.waitForTimeout(250);
		},
		/// Click into a field and type, a key at a time.
		type: async (target, text, { delay = 55 } = {}) => {
			await moveTo(target, true);
			await press();
			await target.click();
			// Off what is about to be typed, as anyone moves the mouse aside.
			const box = await target.boundingBox();
			if (box) {
				at.x = Math.min(box.x + box.width - 24, width - 30);
				at.y = box.y + box.height - 10;
				await cursor(at.x, at.y, false);
			}
			await page.waitForTimeout(200);
			await target.pressSequentially(text, { delay });
		},
		hold: (ms) => page.waitForTimeout(ms),
		/// Scroll the window, smoothly, by `dy`.
		scroll: async (dy) => {
			const steps = Math.max(10, Math.round(Math.abs(dy) / 12));
			for (let i = 0; i < steps; i++) {
				await page.mouse.wheel(0, dy / steps);
				await page.waitForTimeout(16);
			}
		},
		cut: async () => {
			await cdp.send('Page.stopScreencast').catch(() => {});
			await Promise.all(writing);
			writeFileSync(
				join(dir, 'take.json'),
				JSON.stringify({ size, width, height, scale, frames, marks, clicks }, null, 1)
			);
			console.log(
				`  ${take}: ${frames.length} frames, ${now().toFixed(1)} s, marks ${JSON.stringify(marks)}`
			);
		}
	};
}

// The world's own address, as knotless.dev's would read, in the text a page
// shows (never its scripts).
const LIVE = `() => {
  if (window.__live) return;
  window.__live = true;
  const at = /gateway\\.knotless\\.internal(:\\d+)?/g;
  const live = (n) => {
    if (n.nodeType === 3) {
      const p = n.parentElement?.tagName;
      if (p !== 'SCRIPT' && p !== 'STYLE' && at.test(n.data)) n.data = n.data.replace(at, 'knotless.dev');
    } else n.childNodes.forEach(live);
  };
  const start = () => {
    live(document);
    new MutationObserver((ms) => ms.forEach((m) => (m.type === 'characterData' ? live(m.target) : m.addedNodes.forEach(live)))).observe(document, { childList: true, subtree: true, characterData: true });
  };
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
  else start();
}`;

const HIDE_CURSOR = `document.addEventListener('DOMContentLoaded', () => {
  const s = document.createElement('style');
  s.textContent = '#film-cursor svg{display:none!important}';
  document.head.append(s);
});`;

// A cursor drawn into the page, in its top layer over any dialog, since a
// screencast does not show the real one: moved by the camera (moveTo). An
// arrow, or a hand on its way to a click, which presses in as it clicks; the
// video draws the click itself. A pixel blinking in a corner keeps the
// screencast painting while a page is still.
const CURSOR = `(() => {
  if (window !== window.top) return;
  const draw = () => {
    if (document.getElementById('film-cursor')) return;
    const c = document.createElement('div');
    c.id = 'film-cursor';
    c.setAttribute('popover', 'manual');
    c.innerHTML = '<svg class="arrow" width="26" height="26" viewBox="0 0 24 24"><path d="M4 2.5 19 13l-6.6 1.2L16 21.3l-2.9 1.3-3.5-7.2L4 19.6z" fill="#fff" stroke="#111" stroke-width="1.6" stroke-linejoin="round"/></svg><svg class="hand" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#111" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15V4a2 2 0 0 1 4 0v5.5V9a2 2 0 0 1 4 0v1a2 2 0 0 1 3 1z" fill="#fff"/><path d="M11 9.5V4M15 10V9M18 11v-1"/></svg>';
    c.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;transform:translate(640px,400px);will-change:transform';
    const s = document.createElement('style');
    s.textContent = '#film-cursor{inset:auto;margin:0;border:0;padding:0;background:none;overflow:visible;width:0;height:0}#film-cursor svg{position:absolute;max-width:none;max-height:none;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5));transform-origin:30% 10%}#film-cursor .arrow{left:-5px;top:-3px}#film-cursor .hand{left:-10px;top:-3px;display:none}#film-cursor.hand .arrow{display:none}#film-cursor.hand .hand{display:block}#film-cursor.press svg{animation:film-press .3s ease-out}@keyframes film-press{40%{transform:scale(.82)}100%{transform:scale(1)}}#film-tick{position:fixed;right:0;bottom:0;width:1px;height:1px;z-index:2147483647;pointer-events:none;animation:film-tick 1s steps(2) infinite}@keyframes film-tick{0%{background:rgba(128,128,128,.01)}100%{background:rgba(128,128,128,.02)}}';
    const tick = document.createElement('div');
    tick.id = 'film-tick';
    document.documentElement.append(s, c, tick);
    const last = sessionStorage.getItem('film-cursor');
    if (last) c.style.transform = last;
  };
  window.__cursor = (x, y, hand) => {
    draw();
    const c = document.getElementById('film-cursor');
    c.classList.toggle('hand', !!hand);
    try {
      if (c.matches(':popover-open')) c.hidePopover();
      c.showPopover();
    } catch {}
    c.style.transform = 'translate(' + x + 'px,' + y + 'px)';
    sessionStorage.setItem('film-cursor', c.style.transform);
  };
  window.__press = () => {
    const c = document.getElementById('film-cursor');
    if (!c) return;
    c.classList.remove('press');
    void c.offsetWidth;
    c.classList.add('press');
  };
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', draw);
  else draw();
})();`;