Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/mockup/mockup-preview.ts

Raw
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
import {
	allocateImageId,
	type Component,
	getCapabilities,
	getCellDimensions,
	Image,
} from "@earendil-works/pi-tui";
import type { Browser, Page } from "playwright";
import type { MockupRequest } from "./mockup.js";

const PREVIEW_DURATION_MS = 4_000;
const STATIC_OBSERVATION_MS = 600;
const LOOP_PAUSE_MS = 1_000;
const MAX_REPLAY_FRAME_SETS = 120;
const MAX_REPLAY_BYTES = 16 * 1024 * 1024;
const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024;
const MAX_REPLAY_LOOPS = 10;
const MAX_PLAYBACK_FPS = 30;
const PREVIEW_WIDTH_PX = 680;
const PREVIEW_HEIGHT_PX = 304;
const MAX_PREVIEW_HEIGHT_PX = 720;
const PREVIEW_BORDER_PX = 4;

export type PreviewFrameSet = {
	capturedAt: number;
	frames: string[];
	cropped?: boolean[];
};

export type MockupPreview = {
	readonly labels: string[];
	readonly done: Promise<void>;
	current(): readonly string[] | undefined;
	cropped(): readonly boolean[];
	expired(): boolean;
	version(): number;
	subscribe(listener: () => void): () => void;
	close(): void;
};

export type MockupPreviewManager = {
	start(
		id: string,
		request: MockupRequest,
		signal?: AbortSignal,
	): Promise<MockupPreview>;
	get(id: string): MockupPreview | undefined;
	close(): Promise<void>;
};

export class PreviewCancelledError extends Error {
	constructor() {
		super("mockup preview cancelled");
		this.name = "PreviewCancelledError";
	}
}

export function createMockupPreviewManager(): MockupPreviewManager {
	const previews = new Map<string, PreviewSession>();
	let browser: Browser | undefined;
	let browserPromise: Promise<Browser> | undefined;
	let generation = 0;
	let managerClosed = false;

	const acquireBrowser = async (): Promise<Browser> => {
		if (managerClosed) throw new PreviewCancelledError();
		if (browser?.isConnected()) return browser;
		const launch =
			browserPromise ??
			import("playwright").then(({ chromium }) =>
				chromium.launch({ headless: true, timeout: 10_000 }),
			);
		browserPromise = launch;
		try {
			const candidate = await launch;
			if (managerClosed) {
				await candidate.close().catch(() => undefined);
				throw new PreviewCancelledError();
			}
			browser = candidate;
			return candidate;
		} finally {
			if (browserPromise === launch) browserPromise = undefined;
		}
	};

	const pruneSnapshots = (): void => {
		let bytes = [...previews.values()].reduce(
			(total, preview) => total + preview.snapshotBytes(),
			0,
		);
		for (const preview of previews.values()) {
			if (bytes <= MAX_SNAPSHOT_BYTES) break;
			bytes -= preview.snapshotBytes();
			preview.close();
		}
	};

	return {
		async start(id, request, signal) {
			const owner = ++generation;
			for (const preview of previews.values()) preview.freeze();
			pruneSnapshots();
			const assertActive = () => {
				if (managerClosed || signal?.aborted || owner !== generation)
					throw new PreviewCancelledError();
			};
			assertActive();
			const activeBrowser = await acquireBrowser();
			assertActive();
			const page = await activeBrowser.newPage({
				viewport: { width: 720, height: 480 },
				deviceScaleFactor: previewDeviceScaleFactor(
					request.options.length,
					process.stdout.columns,
				),
			});
			const abort = () => void page.close().catch(() => undefined);
			let preview: PreviewSession | undefined;
			signal?.addEventListener("abort", abort, { once: true });
			try {
				assertActive();
				page.setDefaultTimeout(5_000);
				page.setDefaultNavigationTimeout(5_000);
				await page.setContent(renderMockupPreviewPage(request), {
					waitUntil: "load",
					timeout: 5_000,
				});
				await page.waitForFunction(
					() => document.documentElement.dataset.mockupPreviewReady === "true",
					undefined,
					{ timeout: 5_000 },
				);
				assertActive();
				preview = new PreviewSession(request, page, () => {
					if (previews.get(id) === preview) previews.delete(id);
				});
				await preview.start();
				assertActive();
				previews.set(id, preview);
				return preview;
			} catch (error) {
				preview?.close();
				await page.close().catch(() => undefined);
				if (signal?.aborted || owner !== generation || managerClosed)
					throw new PreviewCancelledError();
				throw error;
			} finally {
				signal?.removeEventListener("abort", abort);
			}
		},
		get: (id) => previews.get(id),
		async close() {
			managerClosed = true;
			generation++;
			for (const preview of previews.values()) preview.close();
			previews.clear();
			const launching = browserPromise;
			if (launching)
				await launching.then((value) => value.close()).catch(() => undefined);
			await browser?.close().catch(() => undefined);
			browser = undefined;
		},
	};
}

class PreviewSession implements MockupPreview {
	readonly labels: string[];
	readonly done: Promise<void>;
	private readonly listeners = new Set<() => void>();
	private readonly resolveDone: () => void;
	private readonly rejectDone: (error: Error) => void;
	private recording: PreviewFrameSet[] = [];
	private recordingDurationMs = 0;
	private currentFrames: string[] | undefined;
	private currentCropped: boolean[] = [];
	private currentVersion = 0;
	private completedReplayLoops = 0;
	private disposed = false;
	private frozen = false;
	private doneSettled = false;
	private replayTimer: NodeJS.Timeout | undefined;

	constructor(
		private readonly request: MockupRequest,
		private readonly page: Page,
		private readonly onClose: () => void,
	) {
		this.labels = request.options.map((option) => option.label);
		let resolveDone!: () => void;
		let rejectDone!: (error: Error) => void;
		this.done = new Promise<void>((resolve, reject) => {
			resolveDone = resolve;
			rejectDone = reject;
		});
		this.resolveDone = resolveDone;
		this.rejectDone = rejectDone;
	}

	async start(): Promise<void> {
		const first = await this.capture();
		this.present(first);
		void this.produce(first).then(
			() => this.finish(),
			(error) =>
				this.finish(error instanceof Error ? error : new Error(String(error))),
		);
	}

	current(): readonly string[] | undefined {
		return this.currentFrames;
	}

	cropped(): readonly boolean[] {
		return this.currentCropped;
	}

	expired(): boolean {
		return this.disposed;
	}

	version(): number {
		return this.currentVersion;
	}

	subscribe(listener: () => void): () => void {
		this.listeners.clear();
		this.listeners.add(listener);
		return () => this.listeners.delete(listener);
	}

	snapshotBytes(): number {
		return this.frozen && this.currentFrames
			? this.currentFrames.reduce((total, image) => total + image.length, 0)
			: 0;
	}

	freeze(): void {
		if (this.disposed || this.frozen) return;
		this.frozen = true;
		if (this.replayTimer) clearTimeout(this.replayTimer);
		this.replayTimer = undefined;
		const final = this.recording.at(-1);
		if (final) this.present(final);
		this.recording = [];
		void this.page.close().catch(() => undefined);
		if (!this.doneSettled) {
			this.doneSettled = true;
			this.resolveDone();
		}
	}

	close(): void {
		if (this.disposed) return;
		this.freeze();
		this.disposed = true;
		this.currentFrames = undefined;
		this.currentVersion++;
		for (const listener of this.listeners) listener();
		this.listeners.clear();
		void this.page.close().catch(() => undefined);
		if (!this.doneSettled) {
			this.doneSettled = true;
			this.resolveDone();
		}
		this.onClose();
	}

	private async produce(first: PreviewFrameSet): Promise<void> {
		const startedAt = performance.now();
		this.retain(first);
		let previous = first;
		let sawChange = false;
		while (
			!this.disposed &&
			!this.frozen &&
			performance.now() - startedAt < PREVIEW_DURATION_MS
		) {
			const untilNextFrame =
				1000 / MAX_PLAYBACK_FPS - (performance.now() - previous.capturedAt);
			if (untilNextFrame > 0)
				await new Promise((resolve) => setTimeout(resolve, untilNextFrame));
			const next = await this.capture();
			if (this.disposed || this.frozen) return;
			if (
				!sameFrames(previous.frames, next.frames) ||
				!sameFlags(previous.cropped, next.cropped)
			) {
				sawChange = true;
				this.retain(next);
				this.present(next);
			}
			previous = next;
			if (!sawChange && performance.now() - startedAt >= STATIC_OBSERVATION_MS)
				break;
		}
		this.recordingDurationMs = previous.capturedAt - first.capturedAt;
		if (
			!this.disposed &&
			!this.frozen &&
			sawChange &&
			this.recording.length > 1
		)
			this.scheduleReplay(0, this.endPauseMs());
	}

	private async capture(): Promise<PreviewFrameSet> {
		const cards = this.page.locator(".preview-card");
		const frames: string[] = [];
		for (let index = 0; index < this.request.options.length; index++) {
			const png = await cards.nth(index).screenshot({
				type: "png",
				animations: "allow",
				omitBackground: true,
				timeout: 5_000,
			});
			frames.push(png.toString("base64"));
			if (
				frames.reduce((total, frame) => total + frame.length, 0) >
				MAX_REPLAY_BYTES
			)
				throw new Error("Mockup preview frame set exceeds the 16 MiB limit");
		}
		const cropped = await cards.evaluateAll((elements) =>
			elements.map((card) => {
				const content = card.querySelector<HTMLElement>(".fragment-content");
				if (!content) return false;
				const value =
					content.scrollHeight > content.clientHeight ||
					content.scrollWidth > content.clientWidth;
				card.toggleAttribute("data-preview-cropped", value);
				return value;
			}),
		);
		return { capturedAt: performance.now(), frames, cropped };
	}

	private retain(frame: PreviewFrameSet): void {
		this.recording = thinReplayFrames([...this.recording, frame]);
	}

	private scheduleReplay(index: number, delayMs: number): void {
		this.replayTimer = setTimeout(() => {
			this.replayTimer = undefined;
			if (this.disposed || this.frozen) return;
			const frame = this.recording[index];
			if (!frame) return;
			this.present(frame);
			const nextIndex = index + 1;
			if (nextIndex >= this.recording.length) {
				this.completedReplayLoops++;
				if (this.completedReplayLoops < MAX_REPLAY_LOOPS)
					this.scheduleReplay(0, this.endPauseMs());
				return;
			}
			const next = this.recording[nextIndex];
			this.scheduleReplay(
				nextIndex,
				Math.max(1000 / MAX_PLAYBACK_FPS, next.capturedAt - frame.capturedAt),
			);
		}, delayMs);
		this.replayTimer.unref?.();
	}

	private endPauseMs(): number {
		const final = this.recording.at(-1);
		const first = this.recording[0];
		const uncapturedTail =
			first && final
				? Math.max(
						0,
						this.recordingDurationMs - (final.capturedAt - first.capturedAt),
					)
				: 0;
		return uncapturedTail + LOOP_PAUSE_MS;
	}

	private present(frame: PreviewFrameSet): void {
		this.currentFrames = frame.frames;
		this.currentCropped = frame.cropped ?? [];
		this.currentVersion++;
		for (const listener of this.listeners) listener();
	}

	private finish(error?: Error): void {
		if (this.disposed || this.doneSettled) return;
		this.doneSettled = true;
		void this.page.close().catch(() => undefined);
		if (error) this.rejectDone(error);
		else this.resolveDone();
	}
}

export class MockupPreviewComponent implements Component {
	private imageIds: number[];
	private readonly unsubscribe: () => void;
	private cachedLines: string[] | undefined;
	private complete = false;
	private cachedVersion = -1;
	private cachedWidth = -1;

	constructor(
		private readonly preview: MockupPreview,
		private color: (style: ThemeColor, text: string) => string,
		private requestRender: () => void,
		private showImages: boolean,
	) {
		this.imageIds = preview.labels.map(() => allocateImageId());
		this.unsubscribe = preview.subscribe(() => {
			this.invalidate();
			this.requestRender();
		});
	}

	update(
		color: (style: ThemeColor, text: string) => string,
		requestRender: () => void,
		showImages: boolean,
	): void {
		this.color = color;
		this.requestRender = requestRender;
		if (this.showImages !== showImages) {
			this.showImages = showImages;
			this.invalidate();
		}
	}

	settle(): void {
		if (this.complete) return;
		this.complete = true;
		this.imageIds = this.preview.labels.map(() => allocateImageId());
		this.invalidate();
	}

	render(width: number): string[] {
		const version = this.preview.version();
		if (
			this.cachedLines &&
			this.cachedVersion === version &&
			this.cachedWidth === width
		)
			return this.cachedLines;
		if (this.preview.expired()) {
			this.cachedLines = [this.color("muted", "Preview expired.")];
			return this.cachedLines;
		}
		if (!this.showImages || !getCapabilities().images) {
			this.cachedLines = [
				this.color("warning", "Inline image previews are disabled."),
			];
			return this.cachedLines;
		}
		const frames = this.preview.current();
		if (!frames) {
			this.cachedLines = [this.color("accent", "Preparing previews…")];
			return this.cachedLines;
		}
		const cropped = this.preview.cropped();
		const maxHeightCells =
			frames.length === 1 ? 20 : frames.length === 2 ? 12 : 8;
		const lines: string[] = [];
		for (let index = 0; index < frames.length; index++) {
			if (index > 0) lines.push("");
			lines.push(
				this.color(
					"accent",
					this.preview.labels[index] ?? `Option ${index + 1}`,
				),
			);
			if (cropped[index])
				lines.push(
					this.color("warning", "Preview cropped · use browser review"),
				);
			const image = new Image(
				frames[index] ?? "",
				"image/png",
				{ fallbackColor: (text) => this.color("muted", text) },
				{
					imageId: this.imageIds[index],
					maxWidthCells: Math.min(80, Math.max(1, width - 2)),
					maxHeightCells,
				},
			);
			lines.push(...image.render(width));
		}
		this.cachedLines = lines;
		this.cachedVersion = version;
		this.cachedWidth = width;
		return lines;
	}

	invalidate(): void {
		this.cachedLines = undefined;
		this.cachedVersion = -1;
		this.cachedWidth = -1;
	}

	dispose(): void {
		this.unsubscribe();
	}
}

export function renderMockupPreviewPage(request: MockupRequest): string {
	const cards = request.options
		.map(
			(option) => `<article class="preview-card">
<div class="fragment-content"></div>
<script type="application/json" class="fragment-html">${jsonScript(option.html)}</script>
</article>`,
		)
		.join("\n");
	return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
:root { color-scheme:dark light; --text:#ededed; --muted:#9a9a9a; --line:#ededed; font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
@media (prefers-color-scheme:light) { :root { --text:#151515; --muted:#666; --line:#151515; } }
* { box-sizing:border-box; }
html,body { margin:0; width:720px; background:transparent; color:var(--text); }
body { padding:20px; display:grid; gap:20px; }
.preview-card { width:680px; border:2px solid var(--line); background:transparent; overflow:hidden; }
.fragment-content { display:block; width:676px; min-height:300px; padding:16px; overflow:visible; }
</style>
</head>
<body>${cards}
<script>
(() => {
  document.querySelectorAll('.preview-card').forEach(card => {
    const content = card.querySelector('.fragment-content');
    const data = card.querySelector('.fragment-html');
    const root = content.attachShadow({ mode:'open' });
    root.innerHTML = JSON.parse(data.textContent || '""');
    root.querySelectorAll('script').forEach(old => {
      const script = document.createElement('script');
      [...old.attributes].forEach(attr => script.setAttribute(attr.name, attr.value));
      script.textContent = old.textContent;
      Object.defineProperty(script, 'mockupRoot', { value:root });
      const pending = Boolean(script.src) || script.type === 'module';
      if (pending) {
        script.addEventListener('load', () => script.remove(), { once:true });
        script.addEventListener('error', () => script.remove(), { once:true });
      }
      document.head.append(script);
      if (!pending) script.remove();
    });
    data.remove();
  });
  requestAnimationFrame(() => requestAnimationFrame(() => {
    const contents = [...document.querySelectorAll('.fragment-content')];
    const maximumContentHeight = ${MAX_PREVIEW_HEIGHT_PX - PREVIEW_BORDER_PX};
    const commonHeight = Math.min(
      maximumContentHeight,
      Math.max(300, ...contents.map(content => Math.ceil(content.getBoundingClientRect().height))),
    );
    contents.forEach(content => {
      content.style.height = commonHeight + 'px';
      content.style.overflow = 'hidden';
      const cropped = content.scrollHeight > content.clientHeight || content.scrollWidth > content.clientWidth;
      content.parentElement.toggleAttribute('data-preview-cropped', cropped);
    });
    document.documentElement.dataset.mockupPreviewReady = 'true';
  }));
})();
</script>
</body>
</html>`;
}

export function previewDeviceScaleFactor(
	optionCount: number,
	terminalColumns = 82,
	cells = getCellDimensions(),
): number {
	const maxHeightCells = optionCount === 1 ? 20 : optionCount === 2 ? 12 : 8;
	const widthScale =
		(Math.min(80, Math.max(1, terminalColumns - 2)) * cells.widthPx) /
		PREVIEW_WIDTH_PX;
	const heightScale = (maxHeightCells * cells.heightPx) / PREVIEW_HEIGHT_PX;
	return Math.min(2, Math.max(0.5, Math.min(widthScale, heightScale)));
}

export function thinReplayFrames(
	frames: readonly PreviewFrameSet[],
	maxFrames = MAX_REPLAY_FRAME_SETS,
	maxBytes = MAX_REPLAY_BYTES,
): PreviewFrameSet[] {
	let retained = [...frames];
	let bytes = retained.reduce((total, frame) => total + frameBytes(frame), 0);
	while (
		(retained.length > maxFrames || bytes > maxBytes) &&
		retained.length > 1
	) {
		if (retained.length === 2) {
			retained = [retained[1]];
		} else {
			const last = retained.at(-1);
			retained = retained.filter((_, index) => index % 2 === 0);
			if (last && retained.at(-1) !== last) retained.push(last);
		}
		bytes = retained.reduce((total, frame) => total + frameBytes(frame), 0);
	}
	return bytes <= maxBytes ? retained : [];
}

function frameBytes(frame: PreviewFrameSet): number {
	return frame.frames.reduce((total, image) => total + image.length, 0);
}

function sameFrames(
	left: readonly string[] | undefined,
	right: readonly string[],
): boolean {
	return (
		left?.length === right.length &&
		left.every((frame, index) => frame === right[index])
	);
}

function sameFlags(
	left: readonly boolean[] | undefined,
	right: readonly boolean[] | undefined,
): boolean {
	return (
		left?.length === right?.length &&
		(left?.every((flag, index) => flag === right?.[index]) ?? true)
	);
}

function jsonScript(value: string): string {
	return JSON.stringify(value).replaceAll("<", "\\u003c");
}