Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions scripts/perf-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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' },
Expand All @@ -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&shapes=1` },
{ name: `asleep-${family}`, query: `view=mascots&family=${family}&mood=asleep&shapes=1` },
{ 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;
Expand All @@ -55,9 +72,15 @@ function arg(name: string): string | null {
export class Cdp {
private next = 0;
private waiting = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
private handlers = new Map<string, ((params: unknown) => 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);
Expand Down Expand Up @@ -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<string, number> };
/** 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 }[];
};
Expand Down Expand Up @@ -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<PoseResult> {
async function measure(port: number, base: string, pose: Pose, seconds: number, profile = false, frames = false): Promise<PoseResult> {
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 {
Expand All @@ -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<void> = 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<number[]>(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;
Expand All @@ -204,14 +243,15 @@ async function measure(port: number, base: string, pose: Pose, seconds: number,
})
) as PoseResult['perSecond'];
const animations = await cdp.evaluate<PoseResult['animations']>(ANIMATIONS);
const stamps = motionGaps ? [] : await cdp.evaluate<number[]>(PROBE(seconds * 1000));
const stamps = motionGaps || frames ? [] : await cdp.evaluate<number[]>(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 {
pose: pose.name,
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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions src/MascotGallery.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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=<px>` sets their size (64 by default, the desktop's);
`family=<family>` shows every shape of that family instead.
`family=<family>` shows every shape of that family instead; `mood=<mood>`
(or `mood=asleep`) keeps that one column and `shapes=<n>` the first rows, for measuring one robot at rest.
-->
<script lang="ts">
import { onMount } from 'svelte';
Expand All @@ -20,18 +21,21 @@
{ family: 'capsule', shape: 0, color: 1, eyes: 0, top: 1 },
{ family: 'retro', shape: 0, color: 7, eyes: 1, top: 0 },
];
const MOODS: { mood: Mood; asleep?: boolean; caption: string }[] = [
const ALL_MOODS: { mood: Mood; asleep?: boolean; caption: string }[] = [
{ mood: 'idle', caption: strings.companion.moods.idle },
{ mood: 'working', caption: strings.companion.moods.working },
{ mood: 'calling', caption: strings.companion.moods.calling },
{ mood: 'happy', caption: strings.companion.moods.happy },
{ mood: 'idle', asleep: true, caption: 'z' },
];
const params = new URLSearchParams(location.search);
const only = params.get('mood');
const MOODS = only ? ALL_MOODS.filter((column) => (only === 'asleep' ? column.asleep : column.mood === only && !column.asleep)) : ALL_MOODS;
const size = Number(params.get('size')) || 64;
const family = params.get('family') as RobotFamily | null;
const own = family && ROBOT_FAMILIES.includes(family) ? FAMILIES.find((robot) => robot.family === family)! : null;
const ROBOTS: Robot[] = own ? Array.from({ length: ROBOT_PARTS[own.family].shape }, (_, shape) => ({ ...own, shape, top: (own.top + shape) % ROBOT_PARTS[own.family].top })) : FAMILIES;
const shapes = Number(params.get('shapes')) || Infinity;
const ROBOTS: Robot[] = (own ? Array.from({ length: ROBOT_PARTS[own.family].shape }, (_, shape) => ({ ...own, shape, top: (own.top + shape) % ROBOT_PARTS[own.family].top })) : FAMILIES).slice(0, shapes);
onMount(() => startTheme());
</script>

Expand Down
9 changes: 9 additions & 0 deletions src/bots.css
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,12 @@
:root[data-dormant] *::after {
animation-play-state: paused !important;
}

/*
* A robot out of view (`data-offscreen`, `onscreen` in `lib/companion/beats.ts`):
* scrolled away in a list or clipped, its endless animations wait.
*/
[data-offscreen],
[data-offscreen] * {
animation-play-state: paused !important;
}
11 changes: 9 additions & 2 deletions src/components/agents/RobotFace.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
</script>

<script lang="ts">
import { beat, onscreen } from '../../lib/companion/beats';
import { boxPaint } from '../../lib/companion/skin';
import { robotColor, type Robot } from '../../lib/robots';
import BoxTop from '../companion/BoxTop.svelte';
Expand Down Expand Up @@ -104,6 +105,8 @@
class="robot"
data-family={robot.family}
data-state={state}
use:onscreen
use:beat={{ name: 'blink', every: 4600, length: 368, active: state === 'idle' }}
style:--rb={robotColor(robot)}
style:--eyes-at="{pc(eyesAt[0])} {pc(eyesAt[1])}"
style:--box-body={paint.body}
Expand Down Expand Up @@ -374,14 +377,18 @@

/* A blink at rest, a glance while working, a hop while it waits on the user; moves in hundredths of the square. */
.eyes { transform-origin: var(--eyes-at); }
[data-state='idle'] .eyes { animation: robot-blink 4.6s infinite; }
/* Kept on their own layer between blinks: made at each blink, they would be painted again each time. */
[data-state='idle'] .eyes { will-change: transform; }
/* At rest a blink about every 4.6 s, started by the page's one timer (`beats.ts`). */
.robot:global([data-beat-blink])[data-state='idle'] .eyes { animation: robot-blink 0.368s; }
[data-state='working'] .eyes { animation: robot-glance 2.4s ease-in-out infinite; }
[data-state='working'] .led { animation: robot-pulse 1.2s ease-in-out infinite; }
[data-state='waiting'] .whole { animation: robot-hop 1.8s ease-in-out infinite; }
/* A jelly at work squishes on the spot. */
[data-family='jelly'][data-state='working'] .whole { transform-origin: 50% 96.875%; animation: robot-squish 0.9s ease-in-out infinite; }
@keyframes robot-squish { 0%, 100% { transform: scale(1, 1); } 40% { transform: scale(1.07, 0.93); } 70% { transform: scale(0.96, 1.04); } }
@keyframes robot-blink { 0%, 92%, 100% { transform: scaleY(1); } 95% { transform: scaleY(0.12); } }
/* The last 8 % of the 4.6 s loop it was: shut at 3/8 of the way. */
@keyframes robot-blink { 0%, 100% { transform: scaleY(1); } 37.5% { transform: scaleY(0.12); } }
@keyframes robot-glance { 0%, 20% { transform: translateX(0); } 30%, 50% { transform: translateX(-3.4375%); } 60%, 80% { transform: translateX(3.4375%); } 90%, 100% { transform: translateX(0); } }
@keyframes robot-hop { 0%, 70%, 100% { transform: translateY(0); } 80% { transform: translateY(-4.6875%); } 90% { transform: translateY(0.78125%); } }
@keyframes robot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
Expand Down
9 changes: 7 additions & 2 deletions src/components/companion/CapsuleCharacter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
import RobotFace from '../agents/RobotFace.svelte';
import { faceState, type CharacterProps } from '../../lib/companion/character';
import CharacterMarks from './CharacterMarks.svelte';
import { onscreen } from '../../lib/companion/beats';
import { useSteppedLoops } from '../../lib/companion/stepped';

let { robot, face, tone, look, bouncing, dizzy, act, size, label }: CharacterProps = $props();
// Its doze is drawn in steps (`stepped.ts`); its rock at rest moves too far for that and stays smooth.
useSteppedLoops();
</script>

<div
Expand All @@ -26,6 +30,7 @@
style:height="{size}px"
role="img"
aria-label={label}
use:onscreen
>
<div class="rig">
<div class="fit">
Expand All @@ -51,7 +56,7 @@

/* The rock: a tilt back and forth while it breathes, slower and further over asleep. */
.f-awake .rig, .f-typing .rig, .f-game .rig { animation: rock 4.4s ease-in-out infinite; }
.f-sleep .rig, .f-calm .rig, .f-rest .rig { animation: nap 6s ease-in-out infinite; }
.f-sleep .rig, .f-calm .rig, .f-rest .rig { animation: bots-capsule-nap-6000 6s steps(1, end) infinite; }
.t-working .rig { animation: hum 1.2s ease-in-out infinite; }
.f-groove .rig { animation: groove 0.5s ease-in-out infinite alternate; }
.f-happy .rig { animation: hop 1.2s ease-in-out infinite; }
Expand All @@ -70,7 +75,7 @@
.dizzy .rig :global(.cap) { animation: whirl 0.5s linear infinite; }

@keyframes rock { 0%, 100% { transform: rotate(0) scale(1, 1); } 50% { transform: rotate(6deg) scale(1.01, 1.025); } }
@keyframes nap { 0%, 100% { transform: rotate(-9deg) translateY(2%); } 50% { transform: rotate(-7deg) translateY(2%) scale(1.01, 1.02); } }
/* The doze: `bots-capsule-nap-6000`, written in steps by `stepped.ts`. */
@keyframes hum { 0%, 100% { transform: rotate(0) translateY(0); } 25% { transform: rotate(2deg) translateY(-1.5%); } 75% { transform: rotate(-2deg) translateY(-1.5%); } }
@keyframes groove { from { transform: rotate(-6deg); } to { transform: rotate(6deg) translateY(-1.5%); } }
@keyframes hop { 0%, 60%, 100% { transform: translateY(0) rotate(0); } 30% { transform: translateY(-9%) rotate(8deg); } }
Expand Down
Loading
Loading