@@ -90,7 +97,8 @@
.dizzy .rig :global(.eyes) { animation: spin 0.8s steps(8, end) infinite; }
/* The antenna: a wiggle now and then at rest, all the time at work, waving when it calls. */
- .f-awake .rig :global(.antenna), .f-typing .rig :global(.antenna), .f-game .rig :global(.antenna), .f-groove .rig :global(.antenna) { animation: wiggle 4.2s ease-in-out infinite; }
+ /* No weightier than the rule it replaced: a poke's wave still wins over a wiggle under way. */
+ .retro:global(:where([data-beat-wiggle])) .rig :global(.antenna) { animation: wiggle-once 1.26s ease-in-out; }
.t-working .rig :global(.antenna) { animation: wiggle 1.2s ease-in-out infinite; }
.f-alarm .rig :global(.antenna), .f-calling .rig :global(.antenna) { animation: wave 0.45s ease-in-out infinite alternate; }
.f-happy .rig :global(.antenna), .f-cheer .rig :global(.antenna), .boing .rig :global(.antenna) { animation: wave 0.25s ease-in-out infinite alternate; }
@@ -101,8 +109,8 @@
.scanline { animation: scan 2.8s linear infinite; }
.t-working .scanline { animation-duration: 1.1s; }
.f-sleep .scanline { animation-duration: 6s; opacity: 0.2; }
- .glow { animation: flicker 5s steps(1, end) infinite; }
- .t-working .glow { animation-duration: 1.7s; }
+ .glow:global([data-beat-flicker]) { animation: flicker-once 0.35s steps(1, end); }
+ .t-working .glow { animation: flicker 1.7s steps(1, end) infinite; }
.f-alarm .glow, .f-calling .glow { animation: alert 0.7s steps(1, end) infinite; }
.f-sleep .glow { animation: none; }
@@ -126,9 +134,13 @@
@keyframes rattle { 0%, 100% { transform: rotate(0); } 25% { transform: rotate(-8deg) translateX(-1.5%); } 50% { transform: rotate(0); } 75% { transform: rotate(8deg) translateX(1.5%); } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes wiggle { 0%, 70%, 100% { transform: rotate(0); } 76% { transform: rotate(-12deg); } 84% { transform: rotate(10deg); } 92% { transform: rotate(-5deg); } }
+ /* The moving 30 % of `wiggle`, played once. */
+ @keyframes wiggle-once { 0%, 100% { transform: rotate(0); } 20% { transform: rotate(-12deg); } 46.667% { transform: rotate(10deg); } 73.333% { transform: rotate(-5deg); } }
@keyframes wave { from { transform: rotate(-16deg); } to { transform: rotate(16deg); } }
@keyframes scan { from { transform: translateY(-100%); } to { transform: translateY(420%); } }
@keyframes flicker { 0%, 100% { opacity: 0; } 62% { opacity: 0.14; } 64% { opacity: 0; } 67% { opacity: 0.1; } 69% { opacity: 0; } }
+ /* The lit 7 % of `flicker`, played once. */
+ @keyframes flicker-once { 0% { opacity: 0.14; } 28.571% { opacity: 0; } 71.429% { opacity: 0.1; } 100% { opacity: 0; } }
@keyframes alert { 0%, 100% { opacity: 0; } 50% { opacity: 0.22; } }
@media (prefers-reduced-motion: reduce) {
diff --git a/src/lib/companion/beats.test.ts b/src/lib/companion/beats.test.ts
new file mode 100644
index 0000000..6c07023
--- /dev/null
+++ b/src/lib/companion/beats.test.ts
@@ -0,0 +1,88 @@
+import { expect, test } from 'vitest';
+import { Beats, type Clock } from './beats';
+
+/** A clock the test moves by hand, with the timers it holds. */
+function fakeClock() {
+ let now = 0;
+ let timers: { at: number; callback: () => void; handle: number }[] = [];
+ let handles = 0;
+ const clock: Clock = {
+ now: () => now,
+ set: (callback, ms) => {
+ timers.push({ at: now + ms, callback, handle: ++handles });
+ return handles;
+ },
+ clear: (handle) => (timers = timers.filter((timer) => timer.handle !== handle))
+ };
+ const advance = (ms: number) => {
+ const end = now + ms;
+ for (;;) {
+ const due = timers.filter((timer) => timer.at <= end).sort((a, b) => a.at - b.at)[0];
+ if (!due) break;
+ timers = timers.filter((timer) => timer !== due);
+ now = due.at;
+ due.callback();
+ }
+ now = end;
+ };
+ return { clock, advance, pending: () => timers.length };
+}
+
+/** When the mark was on, over `ms` read every 10 ms. */
+function marks(node: Element, name: string, advance: (ms: number) => void, ms: number): number[] {
+ const on: number[] = [];
+ for (let at = 0; at < ms; at += 10) {
+ advance(10);
+ if (node.hasAttribute(`data-beat-${name}`)) on.push(at);
+ }
+ return on;
+}
+
+test('a gesture shows for its length, about every period, with one timer for every robot', () => {
+ const { clock, advance, pending } = fakeClock();
+ const beats = new Beats(clock, () => 0.5, () => true);
+ const robots = [document.createElement('div'), document.createElement('div'), document.createElement('div')];
+ robots.forEach((robot) => beats.add(robot, { name: 'blink', every: 5000, length: 350 }));
+ expect(pending()).toBe(1);
+ const on = marks(robots[0]!, 'blink', advance, 20_000);
+ // Four blinks in twenty seconds, each 350 ms long.
+ const starts = on.filter((at, index) => index === 0 || at - on[index - 1]! > 10);
+ expect(starts).toHaveLength(4);
+ expect(on.length).toBeGreaterThanOrEqual(4 * 34);
+ expect(on.length).toBeLessThanOrEqual(4 * 36);
+ expect(pending()).toBe(1);
+});
+
+test('the rhythm drifts within its jitter, never in step across robots', () => {
+ const { clock, advance } = fakeClock();
+ let seed = 1;
+ const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
+ const beats = new Beats(clock, random, () => true);
+ const [a, b] = [document.createElement('div'), document.createElement('div')];
+ beats.add(a, { name: 'blink', every: 5000, length: 350 });
+ beats.add(b, { name: 'blink', every: 5000, length: 350 });
+ const onA = marks(a, 'blink', advance, 60_000);
+ const starts = onA.filter((at, index) => index === 0 || at - onA[index - 1]! > 10);
+ const gaps = starts.slice(1).map((at, index) => at - starts[index]!);
+ for (const gap of gaps) {
+ expect(gap).toBeGreaterThanOrEqual(4000 - 20);
+ expect(gap).toBeLessThanOrEqual(6000 + 20);
+ }
+});
+
+test('nothing starts while gestures may not, an inactive face, or after the stop', () => {
+ const { clock, advance, pending } = fakeClock();
+ let may = false;
+ const beats = new Beats(clock, () => 0.5, () => may);
+ const node = document.createElement('div');
+ const handle = beats.add(node, { name: 'blink', every: 1000, length: 100 });
+ expect(marks(node, 'blink', advance, 5000)).toHaveLength(0);
+ may = true;
+ handle.update({ name: 'blink', every: 1000, length: 100, active: false });
+ expect(marks(node, 'blink', advance, 5000)).toHaveLength(0);
+ handle.update({ name: 'blink', every: 1000, length: 100 });
+ expect(marks(node, 'blink', advance, 5000).length).toBeGreaterThan(0);
+ handle.stop();
+ expect(node.hasAttribute('data-beat-blink')).toBe(false);
+ expect(pending()).toBe(0);
+});
diff --git a/src/lib/companion/beats.ts b/src/lib/companion/beats.ts
new file mode 100644
index 0000000..deaefe8
--- /dev/null
+++ b/src/lib/companion/beats.ts
@@ -0,0 +1,167 @@
+/*
+ * The robots' occasional gestures (a blink, a flicker, a twitch of the
+ * antenna), started by one timer for every robot on the page.
+ *
+ * A blink every five seconds written as an endless CSS animation holds
+ * still 93 % of its cycle, yet keeps the compositor awake on every frame of
+ * the display to find nothing changed. Here a gesture is a short animation
+ * that runs only while it shows: `beat` marks the element with
+ * `data-beat-
` for `length` ms, and the stylesheet plays the gesture
+ * once on that mark. One timer serves every robot, each at its own jittered
+ * rhythm around `every`. Nothing starts while the window is out of sight
+ * (`data-dormant`, `App.svelte`), under reduced motion, or for an element
+ * off screen (`onscreen`).
+ */
+
+export interface BeatOptions {
+ /** The gesture, in the mark's name: `data-beat-`. */
+ name: string;
+ /** About how often, in ms. */
+ every: number;
+ /** How long the gesture plays, in ms. */
+ length: number;
+ /** The share of `every` the rhythm drifts by, either way. */
+ jitter?: number;
+ /** Off, the gesture never starts (a face whose eyes are shut). */
+ active?: boolean;
+}
+
+interface Entry {
+ node: Element;
+ options: BeatOptions;
+ /** When it next starts, or ends while it plays. */
+ at: number;
+ playing: boolean;
+}
+
+/** The page's clock and timer, a test's in tests. */
+export interface Clock {
+ now(): number;
+ set(callback: () => void, ms: number): unknown;
+ clear(handle: unknown): void;
+}
+
+const pageClock: Clock = {
+ now: () => performance.now(),
+ set: (callback, ms) => setTimeout(callback, ms),
+ clear: (handle) => clearTimeout(handle as ReturnType)
+};
+
+/** Whether gestures may start: the window in sight and motion allowed. */
+function lively(): boolean {
+ if (typeof document === 'undefined') return false;
+ const root = document.documentElement;
+ if (root.hasAttribute('data-dormant') || root.dataset.motion === 'reduced') return false;
+ return !(typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches);
+}
+
+export class Beats {
+ private readonly entries = new Set();
+ private timer: unknown = null;
+ private timerAt = Infinity;
+
+ constructor(
+ private readonly clock: Clock = pageClock,
+ private readonly random: () => number = Math.random,
+ private readonly may: (node: Element) => boolean = (node) => lively() && !offscreen.has(node)
+ ) {}
+
+ /** Starts `node`'s gesture now and then, until the returned stop. */
+ add(node: Element, options: BeatOptions): { update(options: BeatOptions): void; stop(): void } {
+ // A random first time, as each endless animation started when its robot came: robots never blink together.
+ const entry: Entry = { node, options, at: this.clock.now() + this.random() * options.every, playing: false };
+ this.entries.add(entry);
+ this.arm();
+ return {
+ update: (next) => {
+ entry.options = next;
+ if (next.active === false) this.end(entry);
+ },
+ stop: () => {
+ this.end(entry);
+ this.entries.delete(entry);
+ this.arm();
+ }
+ };
+ }
+
+ private next(options: BeatOptions): number {
+ const jitter = options.jitter ?? 0.2;
+ return options.every * (1 - jitter + 2 * jitter * this.random());
+ }
+
+ private end(entry: Entry): void {
+ if (!entry.playing) return;
+ entry.playing = false;
+ entry.node.removeAttribute(`data-beat-${entry.options.name}`);
+ }
+
+ private tick = (): void => {
+ this.timer = null;
+ this.timerAt = Infinity;
+ const now = this.clock.now();
+ for (const entry of this.entries) {
+ if (entry.at > now) continue;
+ if (entry.playing) {
+ this.end(entry);
+ entry.at = now + Math.max(0, this.next(entry.options) - entry.options.length);
+ } else if (entry.options.active !== false && this.may(entry.node)) {
+ entry.playing = true;
+ entry.node.setAttribute(`data-beat-${entry.options.name}`, '');
+ entry.at = now + entry.options.length;
+ } else entry.at = now + this.next(entry.options);
+ }
+ this.arm();
+ };
+
+ /** One timer, set for the soonest start or end. */
+ private arm(): void {
+ let soonest = Infinity;
+ for (const entry of this.entries) soonest = Math.min(soonest, entry.at);
+ if (soonest === this.timerAt) return;
+ if (this.timer !== null) this.clock.clear(this.timer);
+ this.timer = null;
+ this.timerAt = soonest;
+ if (soonest !== Infinity) this.timer = this.clock.set(this.tick, Math.max(0, soonest - this.clock.now()));
+ }
+}
+
+/** The page's beats: one timer for every robot. */
+export const beats = new Beats();
+
+/** A Svelte action: `use:beat={{ name: 'blink', every: 5000, length: 350 }}`. */
+export function beat(node: Element, options: BeatOptions): { update(options: BeatOptions): void; destroy(): void } {
+ const handle = beats.add(node, options);
+ return { update: handle.update, destroy: handle.stop };
+}
+
+/** The elements out of view, by `onscreen`'s one observer. */
+const offscreen = new WeakSet();
+let observer: IntersectionObserver | null = null;
+
+/**
+ * A Svelte action: while the element is out of view (scrolled away in a
+ * list, clipped by the island), it is marked `data-offscreen`, its endless
+ * animations wait (`bots.css`) and its gestures do not start.
+ */
+export function onscreen(node: Element): { destroy(): void } {
+ if (typeof IntersectionObserver === 'undefined') return { destroy() {} };
+ observer ??= new IntersectionObserver((seen) => {
+ for (const { target, isIntersecting } of seen) {
+ if (isIntersecting) {
+ offscreen.delete(target);
+ target.removeAttribute('data-offscreen');
+ } else {
+ offscreen.add(target);
+ target.setAttribute('data-offscreen', '');
+ }
+ }
+ });
+ observer.observe(node);
+ return {
+ destroy() {
+ observer?.unobserve(node);
+ offscreen.delete(node);
+ }
+ };
+}
diff --git a/src/lib/companion/stepped.test.ts b/src/lib/companion/stepped.test.ts
new file mode 100644
index 0000000..6662b99
--- /dev/null
+++ b/src/lib/companion/stepped.test.ts
@@ -0,0 +1,56 @@
+import { expect, test } from 'vitest';
+import { easeInOut, keyframes, LARGEST_PX, LOOPS, MAX_STEP_PX, poseAt, STEP_HZ, stepsOf, type Loop } from './stepped';
+
+/** Where a point of the robot's square (fractions of its size) is drawn at `t`, in screen pixels at `LARGEST_PX`. */
+function placed(loop: Loop, t: number, [x, y]: [number, number]): [number, number] {
+ const pose = poseAt(loop, t);
+ const [ox, oy] = loop.origin;
+ // As CSS applies `rotate() translateY() scale()` round the origin: scale first, then the move, then the turn.
+ const sx = (x - ox) * pose.scaleX;
+ const sy = (y - oy) * pose.scaleY + pose.translateY / 100;
+ const angle = (pose.rotate * Math.PI) / 180;
+ return [(ox + sx * Math.cos(angle) - sy * Math.sin(angle)) * LARGEST_PX, (oy + sx * Math.sin(angle) + sy * Math.cos(angle)) * LARGEST_PX];
+}
+
+test('no step of a stepped loop moves any corner of the robot by a quarter pixel at its largest', () => {
+ const corners: [number, number][] = [[0, 0], [1, 0], [0, 1], [1, 1]];
+ for (const loop of LOOPS) {
+ const steps = stepsOf(loop);
+ let most = 0;
+ for (let step = 0; step < steps; step++) {
+ for (const corner of corners) {
+ const [ax, ay] = placed(loop, step / steps, corner);
+ const [bx, by] = placed(loop, (step + 1) / steps, corner);
+ most = Math.max(most, Math.hypot(bx - ax, by - ay));
+ }
+ }
+ expect(most, loop.name).toBeLessThan(MAX_STEP_PX);
+ }
+});
+
+test('a loop keeps its rhythm: STEP_HZ samples a second, through the poses it had smooth', () => {
+ for (const loop of LOOPS) {
+ expect(stepsOf(loop)).toBe((loop.ms / 1000) * STEP_HZ);
+ for (const key of loop.keys) {
+ const pose = poseAt(loop, key.at === 1 ? 0 : key.at);
+ expect(pose.scaleX).toBeCloseTo(key.pose.scaleX ?? 1, 6);
+ expect(pose.scaleY).toBeCloseTo(key.pose.scaleY ?? 1, 6);
+ expect(pose.rotate).toBeCloseTo(key.pose.rotate ?? 0, 6);
+ }
+ }
+});
+
+test('the curve between two poses is CSS ease-in-out', () => {
+ expect(easeInOut(0)).toBeCloseTo(0, 6);
+ expect(easeInOut(0.5)).toBeCloseTo(0.5, 6);
+ expect(easeInOut(1)).toBeCloseTo(1, 6);
+ // cubic-bezier(0.42, 0, 0.58, 1) at a quarter of the time: about 0.129.
+ expect(easeInOut(0.25)).toBeCloseTo(0.1291, 3);
+});
+
+test('the keyframes name the loop and hold a pose per step', () => {
+ const css = keyframes(LOOPS[1]!);
+ expect(css.startsWith('@keyframes bots-box-breathe-4000{')).toBe(true);
+ expect(css.match(/%\{/g)).toHaveLength(stepsOf(LOOPS[1]!) + 1);
+ expect(css).toContain('50%{transform:rotate(0deg) translateY(0%) scale(1.012,1.03)}');
+});
diff --git a/src/lib/companion/stepped.ts b/src/lib/companion/stepped.ts
new file mode 100644
index 0000000..dec1b82
--- /dev/null
+++ b/src/lib/companion/stepped.ts
@@ -0,0 +1,131 @@
+/*
+ * The slow loops a robot keeps up at rest (a breath, a doze), drawn in small
+ * steps instead of on every frame of the screen.
+ *
+ * A breath moves the robot by a pixel or two over seconds. Played as a
+ * smooth CSS animation, it makes the compositor draw the window on every
+ * frame of the display: 240 times a second on a 240 Hz screen, for a
+ * motion nobody could tell from a few dozen positions. Here each loop is
+ * sampled `STEP_HZ` times a second along its own eased curve, with the
+ * same rhythm and amplitude, and held between samples (`steps(1, end)`):
+ * the window is drawn `STEP_HZ` times a second. A loop is stepped only
+ * when no step moves any point of the robot by more than `MAX_STEP_PX` at
+ * the largest size it is drawn (`stepped.test.ts` checks it); larger
+ * motions (a hop, a float, the work) stay smooth.
+ *
+ * The keyframes are written once into the page (`useSteppedLoops`), named
+ * `bots---`; a character's stylesheet plays one with
+ * `animation: steps(1, end) infinite`.
+ */
+
+/** How many times a second a stepped loop moves on. */
+export const STEP_HZ = 20;
+/** The most a step may move a point of the robot, in screen pixels. */
+export const MAX_STEP_PX = 0.25;
+/** The largest a stepped robot is drawn: the desktop's 64 px, on a screen scaled to 200 %. */
+export const LARGEST_PX = 128;
+
+/** One transform, in the units the characters' stylesheets use. */
+export interface Pose {
+ rotate?: number;
+ /** In percent of the robot's size. */
+ translateY?: number;
+ scaleX?: number;
+ scaleY?: number;
+}
+
+export interface Loop {
+ name: string;
+ ms: number;
+ /** Where the robot turns and scales round, in fractions of its square. */
+ origin: [number, number];
+ /** The keyframes, `at` from 0 to 1, eased in and out between each. */
+ keys: { at: number; pose: Pose }[];
+}
+
+/** CSS's `ease-in-out`, `cubic-bezier(0.42, 0, 0.58, 1)`: progress at time `t`, both 0 to 1. */
+export function easeInOut(t: number): number {
+ const bezier = (u: number, a: number, b: number) => 3 * a * u * (1 - u) ** 2 + 3 * b * u ** 2 * (1 - u) + u ** 3;
+ let low = 0;
+ let high = 1;
+ for (let round = 0; round < 40; round++) {
+ const middle = (low + high) / 2;
+ if (bezier(middle, 0.42, 0.58) < t) low = middle;
+ else high = middle;
+ }
+ return bezier((low + high) / 2, 0, 1);
+}
+
+const full = (pose: Pose): Required => ({ rotate: pose.rotate ?? 0, translateY: pose.translateY ?? 0, scaleX: pose.scaleX ?? 1, scaleY: pose.scaleY ?? 1 });
+
+/** The loop's pose at `t` (0 to 1 of its cycle), along its eased curve. */
+export function poseAt(loop: Loop, t: number): Required {
+ const keys = loop.keys;
+ let index = keys.findIndex((key) => key.at > t);
+ if (index <= 0) index = keys.length - 1;
+ const from = keys[index - 1]!;
+ const to = keys[index]!;
+ const share = to.at === from.at ? 1 : easeInOut((t - from.at) / (to.at - from.at));
+ const [a, b] = [full(from.pose), full(to.pose)];
+ const mix = (x: number, y: number) => x + (y - x) * share;
+ return { rotate: mix(a.rotate, b.rotate), translateY: mix(a.translateY, b.translateY), scaleX: mix(a.scaleX, b.scaleX), scaleY: mix(a.scaleY, b.scaleY) };
+}
+
+/** How many samples the loop takes over its cycle. */
+export const stepsOf = (loop: Loop): number => Math.round((loop.ms / 1000) * STEP_HZ);
+
+const round = (value: number) => Number(value.toFixed(5));
+
+/** The loop's keyframes, one per step. */
+export function keyframes(loop: Loop): string {
+ const steps = stepsOf(loop);
+ const frames: string[] = [];
+ for (let step = 0; step <= steps; step++) {
+ const pose = poseAt(loop, step / steps);
+ frames.push(`${round((step / steps) * 100)}%{transform:rotate(${round(pose.rotate)}deg) translateY(${round(pose.translateY)}%) scale(${round(pose.scaleX)},${round(pose.scaleY)})}`);
+ }
+ return `@keyframes ${loop.name}{${frames.join('')}}`;
+}
+
+const breath = (family: string, ms: number, origin: [number, number], scaleX: number, scaleY: number): Loop => ({
+ name: `bots-${family}-breathe-${ms}`,
+ ms,
+ origin,
+ keys: [
+ { at: 0, pose: {} },
+ { at: 0.5, pose: { scaleX, scaleY } },
+ { at: 1, pose: {} }
+ ]
+});
+
+/**
+ * Every stepped loop, as each character's stylesheet had it smooth: the
+ * box's breath (`CompanionCharacter`), the Jelly's (`JellyCharacter`) and
+ * the Capsule's doze (`CapsuleCharacter`).
+ */
+export const LOOPS: Loop[] = [
+ breath('box', 3000, [0.5, 0.88], 1.012, 1.03),
+ breath('box', 4000, [0.5, 0.88], 1.012, 1.03),
+ breath('box', 6000, [0.5, 0.88], 1.012, 1.03),
+ breath('jelly', 4000, [0.5, 1], 1.02, 1.04),
+ breath('jelly', 6000, [0.5, 1], 1.02, 1.04),
+ {
+ name: 'bots-capsule-nap-6000',
+ ms: 6000,
+ origin: [0.54, 0.96],
+ keys: [
+ { at: 0, pose: { rotate: -9, translateY: 2 } },
+ { at: 0.5, pose: { rotate: -7, translateY: 2, scaleX: 1.01, scaleY: 1.02 } },
+ { at: 1, pose: { rotate: -9, translateY: 2 } }
+ ]
+ }
+];
+
+/** Writes the stepped loops' keyframes into the page, once. */
+export function useSteppedLoops(): void {
+ if (typeof document === 'undefined' || document.getElementById('bots-stepped-loops')) return;
+ const style = document.createElement('style');
+ style.id = 'bots-stepped-loops';
+ style.textContent = LOOPS.map(keyframes).join('\n');
+ document.head.append(style);
+}
From 1a6017c17e3d1ed6e3878ca986244fcd11e29a6c Mon Sep 17 00:00:00 2001
From: meetsu <96637888+klNuno@users.noreply.github.com>
Date: Wed, 14 Oct 2026 11:22:59 +0000
Subject: [PATCH 2/4] chore(scripts): count compositor frames per idle pose
---
scripts/perf-probe.ts | 61 +++++++++++++++++++++++++++++++++++-----
src/MascotGallery.svelte | 7 +++--
2 files changed, 59 insertions(+), 9 deletions(-)
diff --git a/scripts/perf-probe.ts b/scripts/perf-probe.ts
index 36076d3..4fc4828 100644
--- a/scripts/perf-probe.ts
+++ b/scripts/perf-probe.ts
@@ -11,13 +11,21 @@
* bun x vite build --config scripts/perf.vite.config.ts
* bun x vite preview --config scripts/perf.vite.config.ts --port 5282
* bun scripts/perf-probe.ts --base http://localhost:5282 [--seconds 10]
- * [--only closed,deck-music] [--json out.json] [--profile] [--unthrottled]
+ * [--only closed,deck-music] [--json out.json] [--profile] [--frames] [--unthrottled]
*
* Each pose runs two phases of `--seconds`: the metrics alone, so a probe
* does not add frames to an idle page, then the rAF probe for the intervals.
* `--profile` adds the functions that took the most main thread time
* during the metrics phase, by self time, a browser call named with the
* page's function that made it.
+ * `--frames` traces the compositor over the metrics phase instead and
+ * counts, per second, the frames it drew (`DrawFrame`), the frames it was
+ * woken for (`BeginFrame`) and the frames the display put out. At the
+ * default 60 Hz, a page that animates smoothly draws about 60 a second and
+ * would draw 240 on a 240 Hz screen; a page at rest draws none.
+ * `--unthrottled` makes Chrome draw as fast as the GPU allows: on a
+ * machine whose GPU is shared (a game, another guest), it takes that GPU
+ * from them; keep to the 60 Hz runs there.
* `--unthrottled` lifts Chrome's frame cap, the closest a headless browser
* gets to a high refresh screen: what the page does per frame then shows as
* frames it cannot keep. A headless Linux Chrome says nothing of WebView2's
@@ -31,6 +39,8 @@ import { join } from 'node:path';
/** `motion`: the island opened from the pill and closed again, four times, measured while it moves. */
type Pose = { name: string; query: string; motion?: boolean };
+const FAMILIES = ['box', 'jelly', 'bubble', 'capsule', 'retro'];
+
/** The poses of `src/lib/island/pose.ts` the measures go through. */
export const POSES: Pose[] = [
{ name: 'closed', query: 'pose=collapsed&calm=1' },
@@ -41,7 +51,14 @@ export const POSES: Pose[] = [
{ name: 'deck-music', query: 'pose=deck&tab=music&calm=1' },
{ name: 'assistant', query: 'pose=open&tab=chat&scene=running&calm=1' },
{ name: 'desktop-idle', query: 'pose=desktop&calm=1&sessions=none' },
- { name: 'desktop-working', query: 'pose=desktop' }
+ { name: 'desktop-working', query: 'pose=desktop' },
+ // At rest, for `--frames`: each family's companion awake and asleep (the gallery, `MascotGallery.svelte`), and in the app.
+ ...FAMILIES.flatMap((family) => [
+ { name: `rest-${family}`, query: `view=mascots&family=${family}&mood=idle` },
+ { name: `asleep-${family}`, query: `view=mascots&family=${family}&mood=asleep` },
+ { name: `closed-${family}`, query: `pose=collapsed&calm=1&skin=${family}` },
+ { name: `desktop-${family}`, query: `pose=desktop&calm=1&skin=${family}` }
+ ])
];
const METRICS = ['TaskDuration', 'ScriptDuration', 'LayoutDuration', 'RecalcStyleDuration', 'LayoutCount', 'RecalcStyleCount'] as const;
@@ -55,9 +72,15 @@ function arg(name: string): string | null {
export class Cdp {
private next = 0;
private waiting = new Map void; reject: (error: Error) => void }>();
+ private handlers = new Map void)[]>();
+ /** Hears an event of the page (`Tracing.dataCollected`). */
+ on(method: string, handler: (params: unknown) => void): void {
+ this.handlers.set(method, [...(this.handlers.get(method) ?? []), handler]);
+ }
private constructor(private socket: WebSocket) {
socket.addEventListener('message', (event) => {
- const data = JSON.parse(String(event.data)) as { id?: number; result?: unknown; error?: { message: string } };
+ const data = JSON.parse(String(event.data)) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message: string } };
+ if (data.method) for (const handler of this.handlers.get(data.method) ?? []) handler(data.params);
if (data.id === undefined) return;
const pending = this.waiting.get(data.id);
this.waiting.delete(data.id);
@@ -145,6 +168,8 @@ export type PoseResult = {
perSecond: Record<(typeof METRICS)[number], number>;
frames: { count: number; p50: number; p95: number; p99: number; dropped: number };
animations: { running: number; props: Record };
+ /** With `--frames`: what the compositor did per second over the phase (`DrawFrame`: frames drawn; `BeginFrame`: woken for a frame; `swaps`: frames the display put out). */
+ compositor?: { draws: number; wakes: number; swaps: number };
/** With `--profile`: the heaviest functions, self time in ms over the phase. */
hot?: { at: string; ms: number }[];
};
@@ -174,7 +199,7 @@ function hottest(profile: Profile, count = 12): { at: string; ms: number }[] {
return [...self].sort((a, b) => b[1] - a[1]).slice(0, count).map(([at, ms]) => ({ at, ms: Math.round(ms * 10) / 10 }));
}
-async function measure(port: number, base: string, pose: Pose, seconds: number, profile = false): Promise {
+async function measure(port: number, base: string, pose: Pose, seconds: number, profile = false, frames = false): Promise {
const created = (await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json()) as { id: string; webSocketDebuggerUrl: string };
const cdp = await Cdp.open(created.webSocketDebuggerUrl);
try {
@@ -189,10 +214,24 @@ async function measure(port: number, base: string, pose: Pose, seconds: number,
await cdp.send('Profiler.setSamplingInterval', { interval: 200 });
await cdp.send('Profiler.start');
}
+ const traced: { name: string }[] = [];
+ let traceDone: Promise = Promise.resolve();
+ if (frames) {
+ cdp.on('Tracing.dataCollected', (params) => traced.push(...(params as { value: { name: string }[] }).value));
+ traceDone = new Promise((resolve) => cdp.on('Tracing.tracingComplete', () => resolve()));
+ await cdp.send('Tracing.start', { traceConfig: { includedCategories: ['disabled-by-default-devtools.timeline.frame', 'viz'] }, transferMode: 'ReportEvents' });
+ }
const before = await metrics(cdp);
const moving = pose.motion ? cdp.evaluate(MOTION) : sleep(seconds * 1000).then(() => null);
const motionGaps = await moving;
const after = await metrics(cdp);
+ let compositor: PoseResult['compositor'];
+ if (frames) {
+ await cdp.send('Tracing.end');
+ await traceDone;
+ const count = (name: string) => traced.filter((event) => event.name === name).length / seconds;
+ compositor = { draws: count('DrawFrame'), wakes: count('BeginFrame'), swaps: count('Display::DrawAndSwap') };
+ }
const hot = profile ? hottest((await cdp.send<{ profile: Profile }>('Profiler.stop')).profile) : undefined;
// A moving pose lasts as long as its moves: 4 rounds of two 900 ms moves.
const span = motionGaps ? 7.2 : seconds;
@@ -204,7 +243,7 @@ async function measure(port: number, base: string, pose: Pose, seconds: number,
})
) as PoseResult['perSecond'];
const animations = await cdp.evaluate(ANIMATIONS);
- const stamps = motionGaps ? [] : await cdp.evaluate(PROBE(seconds * 1000));
+ const stamps = motionGaps || frames ? [] : await cdp.evaluate(PROBE(seconds * 1000));
const gaps = (motionGaps ?? stamps.slice(1).map((stamp, index) => stamp - stamps[index]!)).sort((a, b) => a - b);
const p50 = quantile(gaps, 0.5);
return {
@@ -212,6 +251,7 @@ async function measure(port: number, base: string, pose: Pose, seconds: number,
perSecond,
frames: { count: gaps.length, p50, p95: quantile(gaps, 0.95), p99: quantile(gaps, 0.99), dropped: gaps.filter((gap) => gap > p50 * 1.5).length },
animations,
+ ...(compositor ? { compositor } : {}),
...(hot ? { hot } : {})
};
} finally {
@@ -274,6 +314,13 @@ export function table(results: PoseResult[]): string {
return [head, '|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|', ...rows].join('\n');
}
+/** With `--frames`: the compositor's work per second, pose by pose. */
+export function framesTable(results: PoseResult[]): string {
+ const head = '| Pose | Frames drawn/s | Compositor wakes/s | Display swaps/s | Task ms/s | Running anims |';
+ const rows = results.map(({ pose, compositor: c, perSecond: m, animations: a }) => `| ${pose} | ${fixed(c?.draws ?? 0)} | ${fixed(c?.wakes ?? 0)} | ${fixed(c?.swaps ?? 0)} | ${fixed(m.TaskDuration)} | ${a.running} |`);
+ return [head, '|---|---:|---:|---:|---:|---:|', ...rows].join('\n');
+}
+
if (import.meta.main) {
const base = arg('base') ?? 'http://localhost:5174';
const seconds = Number(arg('seconds') ?? 10);
@@ -286,12 +333,12 @@ if (import.meta.main) {
await measure(port, base, poses[0]!, 1);
const results: PoseResult[] = [];
for (const pose of poses) {
- const result = await measure(port, base, pose, seconds, process.argv.includes('--profile'));
+ const result = await measure(port, base, pose, seconds, process.argv.includes('--profile'), process.argv.includes('--frames'));
results.push(result);
for (const { at, ms } of result.hot ?? []) console.error(` ${ms.toFixed(1)} ms ${at}`);
console.error(`${pose.name}: task ${fixed(result.perSecond.TaskDuration)} ms/s, frames p50 ${fixed(result.frames.p50)} ms, anims ${JSON.stringify(result.animations.props)}`);
}
- console.log(table(results));
+ console.log(process.argv.includes('--frames') ? framesTable(results) : table(results));
const json = arg('json');
if (json) writeFileSync(json, JSON.stringify(results, null, 2));
} finally {
diff --git a/src/MascotGallery.svelte b/src/MascotGallery.svelte
index a174202..5fd6ec5 100644
--- a/src/MascotGallery.svelte
+++ b/src/MascotGallery.svelte
@@ -2,7 +2,8 @@
Every companion family in a few moods side by side, for captures and for
looking at them move: `index.html?view=mascots`, in the dev bundle only
(`main.ts`). `size=` sets their size (64 by default, the desktop's);
- `family=` shows every shape of that family instead.
+ `family=` shows every shape of that family instead; `mood=`
+ (or `mood=asleep`) keeps that one column, for measuring a robot at rest.
-->