From 82aec1ba375ad1436248663506fd94c556f63d20 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sat, 23 May 2026 07:31:22 -0300 Subject: [PATCH 1/2] fix: restore seam split headroom --- packages/core/src/merge/seamRepair.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/merge/seamRepair.ts b/packages/core/src/merge/seamRepair.ts index cb427154..2f2df6af 100644 --- a/packages/core/src/merge/seamRepair.ts +++ b/packages/core/src/merge/seamRepair.ts @@ -165,7 +165,7 @@ const MAX_NEAR_SEAM_GAP_PX = 14; const MIN_RESIDUAL_PATCH_GAP_PX = 0.25; const SEAM_SPLIT_INTERNAL_OVERLAP_PX = 1.25; const SEAM_SPLIT_EDGE_OVERLAP_PX = 0.75; -const DEFAULT_SEAM_SPLIT_BUDGET = 20; +const DEFAULT_SEAM_SPLIT_BUDGET = 40; const MIN_PARALLEL_DOT = 0.985; const MIN_FACING_DOT = 0.5; const TRUE_GAP_OVERLAP_AMOUNT_RATIO = 0.175; From 08c6c49afbf16f2a03dd7c13c16a293b871000a0 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sat, 23 May 2026 13:43:21 -0300 Subject: [PATCH 2/2] feat: improve gltf gallery workbench --- .agents/skills/chrome-capture-trace/SKILL.md | 103 ++ .../chrome-capture-trace/agents/openai.yaml | 4 + .../scripts/capture-trace.mjs | 505 ++++++ .../scripts/polycss-nonvoxel-drag-trace.mjs | 22 +- .../scripts/polycss-trace-analysis.mjs | 26 +- .../chrome-capture-trace/scripts/trace.mjs | 443 +++++ AGENTS.md | 2 + bench/lossy-corpus-bench.mjs | 314 +--- bench/lossy-optimizer-bench.mjs | 63 +- bench/notes/BENCH.md | 53 +- bench/notes/PERF_INVESTIGATION.md | 8 +- package.json | 3 +- packages/core/src/atlas/constants.ts | 2 +- packages/core/src/atlas/plan.test.ts | 12 + .../src/cull/cullInteriorPolygons.test.ts | 11 + .../core/src/cull/cullInteriorPolygons.ts | 34 +- packages/core/src/index.ts | 4 +- .../core/src/merge/coverPlanarPolygons.ts | 7 + .../merge/dedupeOverlappingPolygons.test.ts | 32 + .../src/merge/dedupeOverlappingPolygons.ts | 14 + packages/core/src/merge/mergePolygons.test.ts | 49 + packages/core/src/merge/mergePolygons.ts | 25 +- .../core/src/merge/optimizePolygons.test.ts | 282 ++-- packages/core/src/merge/optimizePolygons.ts | 1503 +---------------- packages/core/src/merge/seamRepair.ts | 44 +- packages/core/src/parser/parseGltf.test.ts | 270 ++- packages/core/src/parser/parseGltf.ts | 457 ++++- .../src/parser/solidTextureSamples.test.ts | 94 +- .../core/src/parser/solidTextureSamples.ts | 83 +- packages/core/src/scene/normalize.test.ts | 13 + packages/core/src/scene/normalize.ts | 10 + packages/core/src/types.ts | 29 + packages/polycss/src/api/createPolyScene.ts | 1 + .../src/render/atlas/rasterise.test.ts | 94 ++ .../polycss/src/render/atlas/rasterise.ts | 113 +- packages/react/src/index.ts | 1 - packages/react/src/scene/PolyMesh.tsx | 1 + .../src/scene/atlas/buildAtlasPages.test.ts | 95 +- .../react/src/scene/atlas/buildAtlasPages.ts | 113 +- packages/vue/src/index.ts | 1 - packages/vue/src/scene/PolyMesh.ts | 1 + .../src/scene/atlas/buildAtlasPages.test.ts | 95 +- .../vue/src/scene/atlas/buildAtlasPages.ts | 113 +- .../gallery/glb/model-viewer/astronaut.glb | Bin 0 -> 2869044 bytes .../public/gallery/glb/nasa/crew-lock-bag.glb | Bin 0 -> 609256 bytes .../public/gallery/glb/nasa/cubesat-1u.glb | Bin 0 -> 356280 bytes .../glb/nasa/flight-system-support.glb | Bin 0 -> 443068 bytes .../glb/nasa/gamma-ray-observatory.glb | Bin 0 -> 336676 bytes website/public/gallery/glb/nasa/grace.glb | Bin 0 -> 111224 bytes .../glb/nasa/hubble-space-telescope.glb | Bin 0 -> 2331404 bytes website/public/gallery/glb/nasa/icesat-a.glb | Bin 0 -> 1151936 bytes .../glb/nasa/international-space-station.glb | Bin 0 -> 287872 bytes website/public/gallery/glb/nasa/kepler.glb | Bin 0 -> 22032 bytes .../gallery/glb/nasa/mars-global-surveyor.glb | Bin 0 -> 2084196 bytes .../public/gallery/glb/nasa/opportunity.glb | Bin 0 -> 186920 bytes .../glb/nasa/orbiter-docking-system.glb | Bin 0 -> 841040 bytes .../gallery/glb/nasa/solid-rocket-booster.glb | Bin 0 -> 2665460 bytes .../glb/nasa/space-shuttle-eva-suit.glb | Bin 0 -> 715864 bytes .../glb/nasa/space-shuttle-external-tank.glb | Bin 0 -> 848352 bytes .../public/gallery/glb/nasa/space-shuttle.glb | Bin 0 -> 51460 bytes website/public/gallery/glb/nasa/tdrs-a.glb | Bin 0 -> 96792 bytes .../glb/nasa/wide-field-planetary-camera.glb | Bin 0 -> 289468 bytes .../glb/smithsonian/morse-telegraph-key.glb | Bin 0 -> 218460 bytes .../Dock/folders/useMaterialsFolder.tsx | 46 + website/src/components/Dock/index.ts | 1 + website/src/components/Dock/primitives.tsx | 27 +- website/src/components/Dock/slots.tsx | 5 + .../GalleryWorkbench/GalleryWorkbench.tsx | 57 +- .../GalleryWorkbench/gallery-workbench.css | 146 ++ .../GalleryWorkbench/presets/attributions.ts | 29 + .../presets/presetBuilders.ts | 3 +- .../GalleryWorkbench/presets/presetFiles.ts | 226 ++- .../GalleryWorkbench/presets/presetList.ts | 16 +- .../src/components/Inspector/Inspector.tsx | 286 +++- website/src/components/Inspector/index.ts | 4 +- 75 files changed, 3783 insertions(+), 2212 deletions(-) create mode 100644 .agents/skills/chrome-capture-trace/SKILL.md create mode 100644 .agents/skills/chrome-capture-trace/agents/openai.yaml create mode 100755 .agents/skills/chrome-capture-trace/scripts/capture-trace.mjs rename bench/nonvoxel-drag-trace.mjs => .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs (96%) mode change 100644 => 100755 rename bench/trace-analysis.mjs => .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs (95%) create mode 100755 .agents/skills/chrome-capture-trace/scripts/trace.mjs create mode 100644 packages/polycss/src/render/atlas/rasterise.test.ts create mode 100644 website/public/gallery/glb/model-viewer/astronaut.glb create mode 100644 website/public/gallery/glb/nasa/crew-lock-bag.glb create mode 100644 website/public/gallery/glb/nasa/cubesat-1u.glb create mode 100644 website/public/gallery/glb/nasa/flight-system-support.glb create mode 100644 website/public/gallery/glb/nasa/gamma-ray-observatory.glb create mode 100644 website/public/gallery/glb/nasa/grace.glb create mode 100644 website/public/gallery/glb/nasa/hubble-space-telescope.glb create mode 100644 website/public/gallery/glb/nasa/icesat-a.glb create mode 100644 website/public/gallery/glb/nasa/international-space-station.glb create mode 100644 website/public/gallery/glb/nasa/kepler.glb create mode 100644 website/public/gallery/glb/nasa/mars-global-surveyor.glb create mode 100644 website/public/gallery/glb/nasa/opportunity.glb create mode 100644 website/public/gallery/glb/nasa/orbiter-docking-system.glb create mode 100644 website/public/gallery/glb/nasa/solid-rocket-booster.glb create mode 100644 website/public/gallery/glb/nasa/space-shuttle-eva-suit.glb create mode 100644 website/public/gallery/glb/nasa/space-shuttle-external-tank.glb create mode 100644 website/public/gallery/glb/nasa/space-shuttle.glb create mode 100644 website/public/gallery/glb/nasa/tdrs-a.glb create mode 100644 website/public/gallery/glb/nasa/wide-field-planetary-camera.glb create mode 100644 website/public/gallery/glb/smithsonian/morse-telegraph-key.glb create mode 100644 website/src/components/Dock/folders/useMaterialsFolder.tsx diff --git a/.agents/skills/chrome-capture-trace/SKILL.md b/.agents/skills/chrome-capture-trace/SKILL.md new file mode 100644 index 00000000..b11e9b7b --- /dev/null +++ b/.agents/skills/chrome-capture-trace/SKILL.md @@ -0,0 +1,103 @@ +--- +name: chrome-capture-trace +description: Capture and analyze Chrome/Chromium performance traces with Playwright around a concrete browser interaction. Use when Codex needs to answer where frame time is spent during an update, drag, rotation, scroll, animation, camera movement, light movement, DOM/CSS render change, or other performance-sensitive UI action; especially when the right answer requires per-frame Chrome trace evidence instead of FPS-only guesses. +--- + +# Chrome Capture Trace + +## Core Workflow + +Use Playwright with Chromium and the Chrome DevTools Protocol `Tracing` domain to capture the exact interaction under investigation. + +1. Start from a reproducible page and action. +2. Warm up the page until app-specific readiness is true. +3. Start Chrome tracing and a `requestAnimationFrame` sampler immediately before the action. +4. Mark the action window with `performance.mark()` and `console.timeStamp()`. +5. Perform the action with real Playwright input when the user is asking about input-driven behavior. +6. Stop tracing after a short settle window. +7. Align trace event timestamps to `performance.now()` using the start/end marks. +8. Report where time went: `Scripting`, `Style`, `Layout`, `PrePaint`, `Paint`, compositor main-thread work, compositor impl-thread work, raster, GPU/viz events, and the slowest frame windows. + +Do not draw conclusions from FPS alone. Use FPS/frame-time summaries only as the symptom; use trace groups and top events as the explanation. + +## Polycss Trace Runners + +Use `scripts/trace.mjs` as the front door: + +```bash +pnpm bench:build +node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --mesh glb:Elephant.glb --variant baseline --dom-samples --label elephant-baseline +node .agents/skills/chrome-capture-trace/scripts/trace.mjs drag --mesh teapot --mode baked --frame-details --label teapot-drag +node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --mesh garden --report --markdown-out bench/results/garden-trace.md +node .agents/skills/chrome-capture-trace/scripts/trace.mjs compare bench/results/before.json bench/results/after.json --markdown-out bench/results/trace-compare.md +``` + +Use `trace.mjs motion` for steady bench motion across `perf` and `nonvoxel` pages, cadence buckets, DOM samples, render stats, and tag counts. + +Use `trace.mjs drag` for real `PolyOrbitControls` pointer-drag traces on `nonvoxel-vanilla.html`. This runner knows the non-voxel readiness hooks, camera state, interaction stats, and per-frame page-work samples. + +Use `trace.mjs generic` for arbitrary pages and interactions that are not covered by a polycss bench page. + +When interpreting polycss traces, map the result back to the render model: + +- `FunctionCall`, `EventDispatch`, `FireAnimationFrame`: JS/input work. Unexpected sustained per-frame work is suspicious outside imported skeletal animation. +- `UpdateLayoutTree`, `RecalculateStyles`: style recalculation, often CSS variable or selector invalidation cost. +- `Layout`: layout; should stay low for transform/CSS-var-driven motion. +- `PrePaint`, `Paint`, `PaintArtifactCompositor::Update`, `Layerize`: paint/compositing setup. +- `LayerTreeImpl::UpdateDrawProperties`, `draw_property_utils::ComputeDrawPropertiesOfVisibleLayers`, `LayerTreeHostImpl::PrepareToDraw`, `MainFrame.Draw`, `SubmitCompositorFrame`: compositor-side cost. +- `RasterTask`, image decode events: raster/bitmap work, usually atlas or tile work. + +## Generic Capture + +For arbitrary pages, use `trace.mjs generic`: + +```bash +node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic \ + --url http://127.0.0.1:3000 \ + --ready-js "window.appReady === true" \ + --action drag \ + --selector "#viewport" \ + --drag "480,0" \ + --duration 1200 \ + --steps 90 \ + --summary-out trace-summary.json \ + --trace-out trace.json +``` + +Useful alternatives: + +```bash +node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action wait --sample 3000 +node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action eval --eval "window.rotateScene?.(Math.PI / 2)" +node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action scroll --scroll "0,900" +``` + +## Comparing Runs + +Use `--report` on a runner to generate a Markdown report after capture, or use `trace.mjs report` on an existing summary JSON: + +```bash +node .agents/skills/chrome-capture-trace/scripts/trace.mjs report bench/results/garden.json --markdown-out bench/results/garden.md +``` + +Use `trace.mjs compare` on summary JSON files from any runner: + +```bash +node .agents/skills/chrome-capture-trace/scripts/trace.mjs compare before.json after.json --markdown-out trace-compare.md +``` + +Read positive deltas in `frame_time_*_ms` and trace group `ms/frame` as more expensive after the change. Read positive FPS deltas as better. +Reports include a `Quick Read` section that calls out p95 frame time and the dominant trace group. + +## Reporting + +Keep the report evidence-led and compact: + +- State the exact command, page, viewport, action, warmup, and sample/settle windows. +- Include frame-time p50/p95/p99 and slowest-frame count. +- Identify the dominant trace groups in the action window. +- Name the top Chrome events, not only broad categories. +- For polycss, explicitly say whether the trace supports or violates the "no JS in the render loop" expectation. +- Mention artifacts written, especially the raw trace JSON that can be opened in Chrome DevTools Performance. + +If trace markers are missing, say that alignment is weaker and rerun with marks before making a firm claim. diff --git a/.agents/skills/chrome-capture-trace/agents/openai.yaml b/.agents/skills/chrome-capture-trace/agents/openai.yaml new file mode 100644 index 00000000..f2989865 --- /dev/null +++ b/.agents/skills/chrome-capture-trace/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Chrome Capture Trace" + short_description: "Capture and analyze Chrome performance traces" + default_prompt: "Use $chrome-capture-trace to capture a Chrome trace around an interaction and identify where frame time is spent." diff --git a/.agents/skills/chrome-capture-trace/scripts/capture-trace.mjs b/.agents/skills/chrome-capture-trace/scripts/capture-trace.mjs new file mode 100755 index 00000000..42fdcdd8 --- /dev/null +++ b/.agents/skills/chrome-capture-trace/scripts/capture-trace.mjs @@ -0,0 +1,505 @@ +#!/usr/bin/env node +import { mkdirSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; + +const argv = process.argv.slice(2); + +function flag(name) { + return argv.indexOf(`--${name}`); +} + +function hasFlag(name) { + return flag(name) >= 0 || argv.includes(`--${name}=true`); +} + +function optStr(name, dflt = "") { + const exact = flag(name); + if (exact >= 0) return argv[exact + 1] ?? dflt; + const prefixed = argv.find((arg) => arg.startsWith(`--${name}=`)); + return prefixed ? prefixed.slice(name.length + 3) : dflt; +} + +function optNum(name, dflt) { + const raw = optStr(name); + if (!raw) return dflt; + const value = Number(raw); + return Number.isFinite(value) ? value : dflt; +} + +function optAll(name) { + const values = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === `--${name}` && argv[i + 1]) { + values.push(argv[i + 1]); + i += 1; + } else if (arg.startsWith(`--${name}=`)) { + values.push(arg.slice(name.length + 3)); + } + } + return values; +} + +const HELP = hasFlag("help") || hasFlag("h"); +const URL = optStr("url"); +const ACTION = optStr("action", "wait"); +const SELECTOR = optStr("selector", "body"); +const READY_JS = optStr("ready-js"); +const WAIT_FOR_SELECTOR = optStr("wait-for-selector"); +const EVAL_BODY = optStr("eval"); +const WARMUP_MS = optNum("warmup", 1000); +const SAMPLE_MS = optNum("sample", 1500); +const DURATION_MS = optNum("duration", 1000); +const SETTLE_MS = optNum("settle", 250); +const STEPS = Math.max(1, Math.round(optNum("steps", 60))); +const VIEWPORT = optStr("viewport", "1280x800"); +const TRACE_OUT = optStr("trace-out", "chrome-trace.json"); +const SUMMARY_OUT = optStr("summary-out", "chrome-trace-summary.json"); +const BROWSER_EXECUTABLE = optStr("browser-executable"); +const HEADLESS = !hasFlag("headed"); +const CHROMIUM_ARGS = optAll("chromium-arg"); +const MARK_START = "__chrome_capture_trace_start__"; +const MARK_END = "__chrome_capture_trace_end__"; + +const TRACE_CATEGORIES = [ + "devtools.timeline", + "disabled-by-default-devtools.timeline", + "blink", + "blink.user_timing", + "cc", + "gpu", + "viz", + "renderer.scheduler", +].join(","); + +const EVENT_GROUPS = { + style: ["UpdateLayoutTree", "RecalculateStyles"], + layout: ["Layout"], + prePaint: ["PrePaint"], + paint: ["Paint"], + raster: ["RasterTask", "ImageDecodeTask", "Decode Image"], + script: ["FunctionCall", "EvaluateScript", "EventDispatch", "TimerFire", "FireAnimationFrame"], + compositorMain: [ + "ProxyMain::BeginMainFrame", + "WebFrameWidgetImpl::UpdateLifecycle", + "PaintArtifactCompositor::Update", + "Layerize", + "Commit", + "ProxyImpl::ReadyToCommit", + ], + compositorImpl: [ + "LayerTreeImpl::UpdateDrawProperties", + "LayerTreeImpl::UpdateDrawProperties::CalculateDrawProperties", + "draw_property_utils::ComputeDrawPropertiesOfVisibleLayers", + "LayerTreeHostImpl::PrepareToDraw", + "MainFrame.Draw", + "SubmitCompositorFrame", + ], + gpuViz: [ + "Graphics.Pipeline", + "DisplayScheduler::DrawAndSwap", + "DirectRenderer::DrawFrame", + "DirectRenderer::DrawRenderPass", + ], +}; + +function printHelp() { + console.log(`Usage: + node scripts/capture-trace.mjs --url [options] + +Options: + --url Page to open. + --ready-js Wait until this page expression is truthy. + --wait-for-selector Wait for a selector before warmup. + --warmup Warmup before tracing. Default: 1000 + --settle Wait after the action before stopping. Default: 250 + --viewport Viewport. Default: 1280x800 + --action wait | drag | click | scroll | eval. Default: wait + --sample Wait-action duration. Default: 1500 + --selector Target selector for drag/click. Default: body + --drag Drag delta in CSS pixels. Default: 400,0 + --scroll Mouse wheel delta. Default: 0,800 + --duration Drag duration. Default: 1000 + --steps Drag steps. Default: 60 + --eval JavaScript body for action=eval. + --trace-out Raw trace output. Default: chrome-trace.json + --summary-out Summary JSON output. Default: chrome-trace-summary.json + --browser-executable Use a specific Chrome/Chromium executable. + --chromium-arg Extra Chromium arg, repeatable. + --headed Run headed. +`); +} + +function parsePair(value, fallback) { + const [a, b] = String(value || "").split(",").map((part) => Number(part.trim())); + return { + x: Number.isFinite(a) ? a : fallback.x, + y: Number.isFinite(b) ? b : fallback.y, + }; +} + +function parseViewport(value) { + const match = /^(\d+)x(\d+)$/i.exec(value); + if (!match) return { width: 1280, height: 800 }; + return { width: Number(match[1]), height: Number(match[2]) }; +} + +function quantile(values, q) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length === 0) return null; + const index = (sorted.length - 1) * q; + const lo = Math.floor(index); + const hi = Math.ceil(index); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (index - lo); +} + +function addDuration(map, name, durationMs) { + const entry = map.get(name) ?? { count: 0, durationMs: 0 }; + entry.count += 1; + entry.durationMs += durationMs; + map.set(name, entry); +} + +function serializeTotals(map, limit = 20) { + return [...map.entries()] + .map(([name, entry]) => ({ + name, + count: entry.count, + duration_ms: +entry.durationMs.toFixed(4), + })) + .sort((a, b) => b.duration_ms - a.duration_ms) + .slice(0, limit); +} + +function findTraceMark(events, name) { + return events.find((event) => event?.name === name && Number.isFinite(event?.args?.data?.startTime)) ?? + events.find((event) => event?.name === "TimeStamp" && event?.args?.data?.message === name); +} + +function eventPerfNow(event, tracePerfOffsetMs) { + return ((event.ts + (event.dur ?? 0) / 2) / 1000) - tracePerfOffsetMs; +} + +function framesFromSamples(samples, startPerfNow, endPerfNow) { + return samples + .filter((sample) => Number.isFinite(sample?.dt) && sample.dt > 0 && sample.dt < 2000) + .filter((sample) => sample.t - sample.dt >= startPerfNow && sample.t <= endPerfNow) + .map((sample, index) => ({ + index, + start: sample.t - sample.dt, + end: sample.t, + dt: sample.dt, + groups: new Map(), + events: new Map(), + })); +} + +function frameIndexAt(frames, perfNow) { + let lo = 0; + let hi = frames.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const frame = frames[mid]; + if (perfNow < frame.start) hi = mid - 1; + else if (perfNow > frame.end) lo = mid + 1; + else return mid; + } + return -1; +} + +function summarizeFrames(frames) { + const dts = frames.map((frame) => frame.dt); + const p50 = quantile(dts, 0.5) ?? 0; + const p95 = quantile(dts, 0.95) ?? 0; + const p99 = quantile(dts, 0.99) ?? 0; + return { + count: frames.length, + fps_p50: p50 > 0 ? +(1000 / p50).toFixed(2) : 0, + frame_time_p50_ms: +p50.toFixed(3), + frame_time_p95_ms: +p95.toFixed(3), + frame_time_p99_ms: +p99.toFixed(3), + }; +} + +function summarizeEvents(events, tracePerfOffsetMs, startPerfNow, endPerfNow, frames) { + const eventToGroup = new Map(); + for (const [group, names] of Object.entries(EVENT_GROUPS)) { + for (const name of names) eventToGroup.set(name, group); + } + + const groupTotals = new Map(); + const eventTotals = new Map(); + let completeEventCount = 0; + let completeDurationMs = 0; + + for (const event of events) { + if (event?.ph !== "X" || typeof event.dur !== "number" || !Number.isFinite(event.ts)) continue; + const perfNow = eventPerfNow(event, tracePerfOffsetMs); + if (perfNow < startPerfNow || perfNow > endPerfNow) continue; + const durationMs = event.dur / 1000; + completeEventCount += 1; + completeDurationMs += durationMs; + addDuration(eventTotals, event.name, durationMs); + const group = eventToGroup.get(event.name); + if (group) addDuration(groupTotals, group, durationMs); + + const frameIndex = frameIndexAt(frames, perfNow); + if (frameIndex >= 0) { + const frame = frames[frameIndex]; + addDuration(frame.events, event.name, durationMs); + if (group) addDuration(frame.groups, group, durationMs); + } + } + + const frameDetails = frames + .map((frame) => ({ + index: frame.index, + start_ms: +frame.start.toFixed(3), + end_ms: +frame.end.toFixed(3), + dt_ms: +frame.dt.toFixed(3), + groups: Object.fromEntries( + Object.keys(EVENT_GROUPS).map((group) => [group, +(frame.groups.get(group)?.durationMs ?? 0).toFixed(4)]), + ), + topEvents: serializeTotals(frame.events, 8), + })) + .sort((a, b) => b.dt_ms - a.dt_ms) + .slice(0, 12); + + return { + complete_event_count: completeEventCount, + complete_duration_ms: +completeDurationMs.toFixed(3), + groups: Object.fromEntries( + Object.keys(EVENT_GROUPS).map((group) => { + const total = groupTotals.get(group); + return [group, { + count: total?.count ?? 0, + duration_ms: +(total?.durationMs ?? 0).toFixed(4), + ms_per_frame: frames.length ? +((total?.durationMs ?? 0) / frames.length).toFixed(4) : null, + }]; + }), + ), + topEvents: serializeTotals(eventTotals, 30), + slowestFrames: frameDetails, + }; +} + +async function startRafSampler(page) { + return page.evaluate(() => { + window.__chromeCaptureTraceSamples = []; + window.__chromeCaptureTraceSampling = true; + let last = performance.now(); + const tick = (now) => { + window.__chromeCaptureTraceSamples.push({ t: now, dt: now - last }); + last = now; + if (window.__chromeCaptureTraceSampling) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); +} + +async function stopRafSampler(page) { + return page.evaluate(() => { + window.__chromeCaptureTraceSampling = false; + return window.__chromeCaptureTraceSamples ?? []; + }); +} + +async function startTrace(cdp) { + const events = []; + cdp.on("Tracing.dataCollected", (payload) => { + if (Array.isArray(payload.value)) events.push(...payload.value); + }); + await cdp.send("Performance.enable"); + await cdp.send("Tracing.start", { + transferMode: "ReportEvents", + categories: TRACE_CATEGORIES, + }); + return events; +} + +async function stopTrace(cdp) { + const done = new Promise((resolveDone) => cdp.once("Tracing.tracingComplete", resolveDone)); + await cdp.send("Tracing.end"); + await done; +} + +async function mark(page, name) { + return page.evaluate((markName) => { + performance.mark(markName); + console.timeStamp(markName); + return performance.now(); + }, name); +} + +async function performAction(page) { + if (ACTION === "wait") { + await page.waitForTimeout(SAMPLE_MS); + return { kind: ACTION, sample_ms: SAMPLE_MS }; + } + + if (ACTION === "click") { + await page.locator(SELECTOR).click(); + return { kind: ACTION, selector: SELECTOR }; + } + + if (ACTION === "scroll") { + const delta = parsePair(optStr("scroll"), { x: 0, y: 800 }); + await page.mouse.wheel(delta.x, delta.y); + return { kind: ACTION, delta }; + } + + if (ACTION === "eval") { + if (!EVAL_BODY) throw new Error("--eval is required for --action eval"); + await page.evaluate((body) => { + return new Function(body)(); + }, EVAL_BODY); + return { kind: ACTION, eval: EVAL_BODY }; + } + + if (ACTION === "drag") { + const delta = parsePair(optStr("drag"), { x: 400, y: 0 }); + const box = await page.locator(SELECTOR).boundingBox(); + if (!box) throw new Error(`Could not find a bounding box for selector: ${SELECTOR}`); + const start = { + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }; + const delayMs = DURATION_MS / STEPS; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + for (let step = 1; step <= STEPS; step += 1) { + const t = step / STEPS; + await page.mouse.move(start.x + delta.x * t, start.y + delta.y * t); + if (delayMs > 0) await page.waitForTimeout(delayMs); + } + await page.mouse.up(); + return { + kind: ACTION, + selector: SELECTOR, + delta, + duration_ms: DURATION_MS, + steps: STEPS, + start: { x: +start.x.toFixed(3), y: +start.y.toFixed(3) }, + }; + } + + throw new Error(`Unknown --action "${ACTION}". Expected wait, drag, click, scroll, or eval.`); +} + +async function loadPlaywright() { + try { + return await import("playwright"); + } catch (firstError) { + try { + const requireFromCwd = createRequire(resolve(process.cwd(), "package.json")); + return requireFromCwd("playwright"); + } catch (secondError) { + throw new Error( + "Could not load Playwright. Run this script from a project that has playwright installed, or install playwright where the skill is located.", + { cause: secondError ?? firstError }, + ); + } + } +} + +async function run() { + if (HELP || !URL) { + printHelp(); + process.exit(URL || HELP ? 0 : 1); + } + + const { chromium } = await loadPlaywright(); + const viewport = parseViewport(VIEWPORT); + const launchOptions = { headless: HEADLESS, args: CHROMIUM_ARGS }; + if (BROWSER_EXECUTABLE) launchOptions.executablePath = BROWSER_EXECUTABLE; + + const browser = await chromium.launch(launchOptions); + try { + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + const diagnostics = []; + page.on("console", (message) => { + if (message.type() === "error" || message.type() === "warning") { + diagnostics.push(`[console:${message.type()}] ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + diagnostics.push(`[pageerror] ${error?.stack || error?.message || error}`); + }); + + await page.goto(URL, { waitUntil: "load" }); + if (WAIT_FOR_SELECTOR) await page.waitForSelector(WAIT_FOR_SELECTOR, { timeout: 30000 }); + if (READY_JS) { + await page.waitForFunction((expr) => Boolean(new Function(`return (${expr});`)()), READY_JS, { timeout: 30000 }); + } + await page.waitForTimeout(WARMUP_MS); + + const cdp = await context.newCDPSession(page); + const traceEvents = await startTrace(cdp); + await startRafSampler(page); + const startPerfNow = await mark(page, MARK_START); + const action = await performAction(page); + await page.waitForTimeout(SETTLE_MS); + const endPerfNow = await mark(page, MARK_END); + const samples = await stopRafSampler(page); + await stopTrace(cdp); + + const startMark = findTraceMark(traceEvents, MARK_START); + const endMark = findTraceMark(traceEvents, MARK_END); + const aligned = Boolean(startMark?.args?.data?.startTime && endMark?.args?.data?.startTime); + const tracePerfOffsetMs = aligned ? (startMark.ts / 1000) - startMark.args.data.startTime : 0; + const alignedStartPerfNow = aligned ? startMark.args.data.startTime : startPerfNow; + const alignedEndPerfNow = aligned ? endMark.args.data.startTime : endPerfNow; + const frames = aligned ? framesFromSamples(samples, alignedStartPerfNow, alignedEndPerfNow) : []; + const eventSummary = aligned + ? summarizeEvents(traceEvents, tracePerfOffsetMs, alignedStartPerfNow, alignedEndPerfNow, frames) + : summarizeEvents(traceEvents, 0, -Infinity, Infinity, frames); + + const summary = { + kind: "chrome-capture-trace", + url: URL, + viewport, + action, + warmup_ms: WARMUP_MS, + settle_ms: SETTLE_MS, + trace_aligned_to_marks: aligned, + trace_perf_offset_ms: aligned ? +tracePerfOffsetMs.toFixed(3) : null, + action_window_ms: +(alignedEndPerfNow - alignedStartPerfNow).toFixed(3), + frames: summarizeFrames(frames), + trace: { + event_count: traceEvents.length, + ...eventSummary, + }, + outputFiles: { + trace: resolve(TRACE_OUT), + summary: resolve(SUMMARY_OUT), + }, + diagnostics, + }; + + mkdirSync(dirname(resolve(TRACE_OUT)), { recursive: true }); + writeFileSync(resolve(TRACE_OUT), JSON.stringify({ + traceEvents, + displayTimeUnit: "ms", + metadata: { + source: "chrome-capture-trace/scripts/capture-trace.mjs", + url: URL, + action, + }, + })); + + mkdirSync(dirname(resolve(SUMMARY_OUT)), { recursive: true }); + writeFileSync(resolve(SUMMARY_OUT), `${JSON.stringify(summary, null, 2)}\n`); + + console.log(JSON.stringify(summary, null, 2)); + } finally { + await browser.close(); + } +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/bench/nonvoxel-drag-trace.mjs b/.agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs old mode 100644 new mode 100755 similarity index 96% rename from bench/nonvoxel-drag-trace.mjs rename to .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs index 912c6989..bbb0513f --- a/bench/nonvoxel-drag-trace.mjs +++ b/.agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs @@ -7,11 +7,11 @@ * and writes both a Chrome trace file and a compact JSON summary. * * Usage: - * node bench/nonvoxel-drag-trace.mjs - * node bench/nonvoxel-drag-trace.mjs --mesh teapot --mode baked --label teapot-drag - * node bench/nonvoxel-drag-trace.mjs --degrees 360 --drag-ms 1500 --steps 120 - * node bench/nonvoxel-drag-trace.mjs --variant force-atlas --trace-out bench/results/teapot.trace.json - * node bench/nonvoxel-drag-trace.mjs --frame-details --no-print-json + * node .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs + * node .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs --mesh teapot --mode baked --label teapot-drag + * node .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs --degrees 360 --drag-ms 1500 --steps 120 + * node .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs --variant force-atlas --trace-out bench/results/teapot.trace.json + * node .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs --frame-details --no-print-json */ import { createServer } from "node:http"; import { mkdirSync, writeFileSync } from "node:fs"; @@ -19,12 +19,12 @@ import { readFile } from "node:fs/promises"; import { dirname, extname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; -import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs"; -import { getNonVoxelVariantParams, knownNonVoxelVariantIds } from "./nonvoxel-variants.mjs"; +import { chromiumArgsWithGpuDefault } from "../../../../bench/chromium-defaults.mjs"; +import { getNonVoxelVariantParams, knownNonVoxelVariantIds } from "../../../../bench/nonvoxel-variants.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(__dirname, ".."); -const benchDir = resolve(repoRoot, "bench"); +const benchDir = resolve(__dirname, "../../../../bench"); +const repoRoot = resolve(benchDir, ".."); const galleryDir = resolve(repoRoot, "website/public/gallery"); const argv = process.argv.slice(2); @@ -68,7 +68,7 @@ const STEPS = Math.max(1, Math.round(optNum("steps", 120))); const VIEWPORT_WIDTH = Math.max(320, Math.round(optNum("viewport-width", 1280))); const VIEWPORT_HEIGHT = Math.max(240, Math.round(optNum("viewport-height", 800))); const LABEL = optStr("label"); -const JSON_PATH = optStr("json"); +const JSON_PATH = optStr("json") || optStr("summary-out"); const TRACE_PATH = optStr("trace-out"); const FRAME_DETAILS = hasFlag("frame-details"); const FRAME_DETAILS_LIMIT = Math.max(0, Math.round(optNum("frame-details-limit", 24))); @@ -751,7 +751,7 @@ async function run() { traceEvents: events, displayTimeUnit: "ms", metadata: { - source: "bench/nonvoxel-drag-trace.mjs", + source: ".agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs", mesh: MESH, mode: MODE, variant: VARIANT, diff --git a/bench/trace-analysis.mjs b/.agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs similarity index 95% rename from bench/trace-analysis.mjs rename to .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs index 5f042ecd..221a2664 100644 --- a/bench/trace-analysis.mjs +++ b/.agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs @@ -6,10 +6,10 @@ * samples, and reports compositor/style/raster/script cost per cadence bucket. * * Usage: - * node bench/trace-analysis.mjs - * node bench/trace-analysis.mjs --mesh ancient-crash-site --runs 3 --dom-samples - * node bench/trace-analysis.mjs --mesh obj-house3 --renderer vanilla --label obj-house3-trace - * node bench/trace-analysis.mjs --page nonvoxel --mesh glb:Elephant.glb --variant order-tile4 --no-trace + * node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs + * node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs --mesh ancient-crash-site --runs 3 --dom-samples + * node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs --mesh obj-house3 --renderer vanilla --label obj-house3-trace + * node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs --page nonvoxel --mesh glb:Elephant.glb --variant order-tile4 --no-trace */ import { createServer } from "node:http"; import { mkdirSync, writeFileSync } from "node:fs"; @@ -17,12 +17,12 @@ import { readFile } from "node:fs/promises"; import { dirname, extname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; -import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs"; -import { getNonVoxelVariantParams, knownNonVoxelVariantIds } from "./nonvoxel-variants.mjs"; +import { chromiumArgsWithGpuDefault } from "../../../../bench/chromium-defaults.mjs"; +import { getNonVoxelVariantParams, knownNonVoxelVariantIds } from "../../../../bench/nonvoxel-variants.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(__dirname, ".."); -const benchDir = resolve(repoRoot, "bench"); +const benchDir = resolve(__dirname, "../../../../bench"); +const repoRoot = resolve(benchDir, ".."); const galleryDir = resolve(repoRoot, "website/public/gallery"); const argv = process.argv.slice(2); @@ -61,6 +61,7 @@ const WARMUP_MS = optNum("warmup", 1500); const SAMPLE_MS = optNum("sample", 6000); const RUNS = optNum("runs", 1); const LABEL = optStr("label"); +const SUMMARY_PATH = optStr("summary-out"); const HEADED = hasFlag("headed"); const JSON_ONLY = hasFlag("json"); const TRACE = !hasFlag("no-trace"); @@ -147,7 +148,7 @@ const KEY_EVENTS = [ ]; function printHelp() { - console.log(`Usage: node bench/trace-analysis.mjs [options] + console.log(`Usage: node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs [options] Options: --page perf | nonvoxel. Default: perf @@ -160,6 +161,7 @@ Options: --warmup Warmup window. Default: 1500 --sample Trace sample window. Default: 6000 --label Write bench/results/.json + --summary-out Write summary JSON to an explicit file --no-trace Collect rAF bucket stats without Chrome tracing --dom-samples Sample mounted leaf/tag counts by rAF frame --headed Run headed Chromium @@ -671,10 +673,10 @@ try { sample_ms: SAMPLE_MS, runs, }; - if (LABEL) { - const dir = resolve(repoRoot, "bench/results"); + if (LABEL || SUMMARY_PATH) { + const file = SUMMARY_PATH ? resolve(SUMMARY_PATH) : resolve(repoRoot, "bench/results", `${LABEL}.json`); + const dir = dirname(file); mkdirSync(dir, { recursive: true }); - const file = resolve(dir, `${LABEL}.json`); writeFileSync(file, JSON.stringify(out, null, 2) + "\n"); if (!JSON_ONLY) console.log(`[trace-analysis] wrote ${file}`); } diff --git a/.agents/skills/chrome-capture-trace/scripts/trace.mjs b/.agents/skills/chrome-capture-trace/scripts/trace.mjs new file mode 100755 index 00000000..bf1a9515 --- /dev/null +++ b/.agents/skills/chrome-capture-trace/scripts/trace.mjs @@ -0,0 +1,443 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, "../../../.."); +const argv = process.argv.slice(2); +const command = argv[0] ?? "help"; +const rest = argv.slice(1); + +const RUNNERS = new Map([ + ["polycss-motion", "polycss-trace-analysis.mjs"], + ["motion", "polycss-trace-analysis.mjs"], + ["polycss-buckets", "polycss-trace-analysis.mjs"], + ["buckets", "polycss-trace-analysis.mjs"], + ["polycss-drag", "polycss-nonvoxel-drag-trace.mjs"], + ["drag", "polycss-nonvoxel-drag-trace.mjs"], + ["generic", "capture-trace.mjs"], + ["capture", "capture-trace.mjs"], +]); + +function printHelp() { + console.log(`Usage: + node .agents/skills/chrome-capture-trace/scripts/trace.mjs [options] + +Commands: + polycss-motion Steady perf/nonvoxel bench trace buckets. + polycss-drag Real PolyOrbitControls pointer-drag trace. + generic Generic Chrome trace around a URL/action. + report Create a Markdown report from one summary JSON. + compare Compare two summary JSON files and print Markdown. + +Aliases: + motion, buckets -> polycss-motion + drag -> polycss-drag + capture -> generic + +Examples: + node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --mesh glb:Elephant.glb --dom-samples --label elephant + node .agents/skills/chrome-capture-trace/scripts/trace.mjs drag --mesh teapot --degrees 360 --frame-details --label teapot-drag + node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action drag --selector "#viewport" + node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --report --markdown-out bench/results/garden.md + node .agents/skills/chrome-capture-trace/scripts/trace.mjs report bench/results/garden.json --markdown-out bench/results/garden.md + node .agents/skills/chrome-capture-trace/scripts/trace.mjs compare before.json after.json --markdown-out report.md +`); +} + +function runScript(scriptName, args) { + const scriptPath = resolve(scriptDir, scriptName); + const result = spawnSync(process.execPath, [scriptPath, ...args], { stdio: "inherit" }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +function stripTraceOptions(args) { + const out = []; + let report = false; + let markdownOut = ""; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--report") { + report = true; + continue; + } + if (arg === "--markdown-out") { + markdownOut = args[i + 1] ?? ""; + i += 1; + continue; + } + if (arg.startsWith("--markdown-out=")) { + markdownOut = arg.slice("--markdown-out=".length); + continue; + } + out.push(arg); + } + return { childArgs: out, report, markdownOut }; +} + +function appendArg(args, name, value) { + return [...args, `--${name}`, value]; +} + +function argValue(args, name) { + const index = args.indexOf(`--${name}`); + if (index >= 0) return args[index + 1]; + const prefixed = args.find((arg) => arg.startsWith(`--${name}=`)); + return prefixed ? prefixed.slice(name.length + 3) : ""; +} + +function positionalArgs(args) { + const out = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg.startsWith("--")) { + out.push(arg); + continue; + } + if (!arg.includes("=") && args[i + 1] && !args[i + 1].startsWith("--")) i += 1; + } + return out; +} + +function readJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} + +function safeLabel(value) { + return String(value ?? "") + .replace(/[^a-z0-9]+/gi, "-") + .replace(/^-|-$/g, "") + .toLowerCase() || "run"; +} + +function firstRun(summary) { + return Array.isArray(summary.runs) ? summary.runs[0] : summary; +} + +function frameMetrics(summary) { + const run = firstRun(summary); + const frames = run.frames ?? run; + return { + kind: summary.kind ?? run.kind ?? "", + label: summary.label ?? run.label ?? summary.mesh ?? run.mesh ?? "", + fps_p50: numberOrNull(frames.fps_p50), + fps_p95: numberOrNull(frames.fps_p95), + frame_time_p50_ms: numberOrNull(frames.frame_time_p50_ms), + frame_time_p95_ms: numberOrNull(frames.frame_time_p95_ms), + frame_time_p99_ms: numberOrNull(frames.frame_time_p99_ms), + frame_count: numberOrNull(frames.count ?? frames.sample_count), + poly_count: numberOrNull(run.polyCount ?? summary.polyCount), + }; +} + +function numberOrNull(value) { + return Number.isFinite(value) ? Number(value) : null; +} + +function groupMsPerFrame(summary) { + const run = firstRun(summary); + if (Array.isArray(run.buckets)) { + const totals = new Map(); + let frameCount = 0; + for (const bucket of run.buckets) { + const count = Number(bucket.frameCount) || 0; + frameCount += count; + for (const [group, value] of Object.entries(bucket.groups_ms_per_frame ?? {})) { + totals.set(group, (totals.get(group) ?? 0) + (Number(value) || 0) * count); + } + } + return Object.fromEntries([...totals.entries()].map(([group, total]) => [group, frameCount ? total / frameCount : 0])); + } + + const frames = frameMetrics(summary).frame_count ?? 0; + const groups = summary.trace?.groups ?? run.trace?.groups ?? {}; + return Object.fromEntries(Object.entries(groups).map(([group, value]) => { + if (Number.isFinite(value?.ms_per_frame)) return [group, Number(value.ms_per_frame)]; + if (Number.isFinite(value?.duration_ms) && frames > 0) return [group, Number(value.duration_ms) / frames]; + return [group, 0]; + })); +} + +function sortedGroups(summary) { + return Object.entries(groupMsPerFrame(summary)) + .filter(([, value]) => Number.isFinite(value)) + .sort((a, b) => b[1] - a[1]); +} + +function dominantGroup(summary) { + return sortedGroups(summary).find(([, value]) => value > 0) ?? null; +} + +function groupHint(group) { + const hints = { + script: "main-thread JavaScript/input work", + style: "style recalculation or CSS variable invalidation", + layout: "layout work", + prePaint: "pre-paint lifecycle work", + paint: "paint work", + raster: "raster or image decode work", + compositorMain: "main-thread compositing setup", + compositorImpl: "compositor impl-thread work", + gpuViz: "GPU/viz drawing pipeline", + }; + return hints[group] ?? "trace event work"; +} + +function quickRead(summary) { + const frames = frameMetrics(summary); + const dominant = dominantGroup(summary); + const lines = []; + if (Number.isFinite(frames.frame_time_p95_ms)) { + lines.push(`- p95 frame time: ${fmt(frames.frame_time_p95_ms)}ms.`); + } + if (dominant) { + lines.push(`- Dominant trace group: \`${dominant[0]}\` at ${fmt(dominant[1], 4)}ms/frame, pointing at ${groupHint(dominant[0])}.`); + } else { + lines.push("- No non-zero trace group cost was attributed in the measured window."); + } + return lines; +} + +function topEvents(summary, limit = 12) { + const run = firstRun(summary); + const raw = summary.trace?.topEvents ?? run.trace?.topEvents ?? run.eventTotals ?? []; + return raw + .map((event) => ({ + name: event.name ?? event.event ?? "", + count: numberOrNull(event.count), + duration_ms: numberOrNull(event.duration_ms), + ms_per_frame: numberOrNull(event.ms_per_frame), + })) + .filter((event) => event.name) + .slice(0, limit); +} + +function summaryMetadata(summary) { + const run = firstRun(summary); + const action = summary.action?.kind ? `${summary.action.kind}` : ""; + return { + kind: summary.kind ?? run.kind ?? "", + page: summary.page ?? run.page ?? "", + mesh: summary.mesh ?? run.mesh ?? "", + renderer: summary.renderer ?? run.renderer ?? "", + variant: summary.variant ?? run.variant ?? "", + mode: summary.mode ?? run.mode ?? "", + motion: summary.motion ?? run.motion ?? "", + action, + url: summary.url ?? run.url ?? "", + }; +} + +function fmt(value, digits = 3) { + return value === null || value === undefined || !Number.isFinite(value) ? "" : Number(value).toFixed(digits); +} + +function fmtDelta(before, after, digits = 3) { + if (!Number.isFinite(before) || !Number.isFinite(after)) return ""; + const delta = after - before; + const sign = delta > 0 ? "+" : ""; + const pct = before === 0 ? "" : ` (${sign}${((delta / before) * 100).toFixed(1)}%)`; + return `${sign}${delta.toFixed(digits)}${pct}`; +} + +function metricRow(name, before, after, digits = 3) { + return `| ${name} | ${fmt(before, digits)} | ${fmt(after, digits)} | ${fmtDelta(before, after, digits)} |`; +} + +function summaryMarkdown(summary, file = "") { + const meta = summaryMetadata(summary); + const frames = frameMetrics(summary); + const groups = groupMsPerFrame(summary); + const events = topEvents(summary); + const contextRows = Object.entries(meta).filter(([, value]) => value); + const groupRows = Object.entries(groups).sort((a, b) => b[1] - a[1]); + + const lines = [ + "# Chrome Trace Report", + "", + ...(file ? [`Source: \`${file}\``, ""] : []), + "## Quick Read", + "", + ...quickRead(summary), + "", + "## Context", + "", + "| Field | Value |", + "| --- | --- |", + ...contextRows.map(([name, value]) => `| ${name} | ${String(value).replaceAll("|", "\\|")} |`), + "", + "## Frame Metrics", + "", + "| Metric | Value |", + "| --- | ---: |", + `| fps_p50 | ${fmt(frames.fps_p50, 2)} |`, + `| fps_p95 | ${fmt(frames.fps_p95, 2)} |`, + `| frame_time_p50_ms | ${fmt(frames.frame_time_p50_ms)} |`, + `| frame_time_p95_ms | ${fmt(frames.frame_time_p95_ms)} |`, + `| frame_time_p99_ms | ${fmt(frames.frame_time_p99_ms)} |`, + `| frame_count | ${fmt(frames.frame_count, 0)} |`, + `| poly_count | ${fmt(frames.poly_count, 0)} |`, + "", + "## Trace Groups", + "", + "| Group | ms/frame |", + "| --- | ---: |", + ...groupRows.map(([group, value]) => `| ${group} | ${fmt(value, 4)} |`), + "", + "## Top Events", + "", + "| Event | Count | Duration ms | ms/frame |", + "| --- | ---: | ---: | ---: |", + ...events.map((event) => + `| ${event.name.replaceAll("|", "\\|")} | ${fmt(event.count, 0)} | ${fmt(event.duration_ms)} | ${fmt(event.ms_per_frame, 4)} |` + ), + "", + ]; + + return `${lines.join("\n")}\n`; +} + +function writeOrPrintMarkdown(markdown, outFile) { + if (outFile) writeFileSync(outFile, markdown); + process.stdout.write(markdown); +} + +function reportSummary(args) { + const positions = positionalArgs(args); + const file = argValue(args, "summary") || positions[0]; + if (!file) throw new Error("report requires a summary JSON file."); + writeOrPrintMarkdown(summaryMarkdown(readJson(file), file), argValue(args, "markdown-out")); +} + +function compareSummaries(args) { + const positions = positionalArgs(args); + const beforeFile = argValue(args, "before") || positions[0]; + const afterFile = argValue(args, "after") || positions[1]; + if (!beforeFile || !afterFile) { + throw new Error("compare requires before and after JSON files."); + } + + const before = readJson(beforeFile); + const after = readJson(afterFile); + const beforeFrames = frameMetrics(before); + const afterFrames = frameMetrics(after); + const beforeGroups = groupMsPerFrame(before); + const afterGroups = groupMsPerFrame(after); + const groupNames = [...new Set([...Object.keys(beforeGroups), ...Object.keys(afterGroups)])].sort(); + const groupDeltas = groupNames + .map((group) => ({ group, before: beforeGroups[group] ?? 0, after: afterGroups[group] ?? 0 })) + .map((entry) => ({ ...entry, delta: entry.after - entry.before })) + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); + const dominantDelta = groupDeltas.find((entry) => Math.abs(entry.delta) > 0.00005) ?? null; + const p95Delta = Number.isFinite(beforeFrames.frame_time_p95_ms) && Number.isFinite(afterFrames.frame_time_p95_ms) + ? afterFrames.frame_time_p95_ms - beforeFrames.frame_time_p95_ms + : null; + + const lines = [ + "# Chrome Trace Compare", + "", + `Before: \`${beforeFile}\``, + `After: \`${afterFile}\``, + "", + "## Quick Read", + "", + ...(Number.isFinite(p95Delta) + ? [`- p95 frame time delta: ${fmtDelta(beforeFrames.frame_time_p95_ms, afterFrames.frame_time_p95_ms)}.`] + : []), + ...(dominantDelta + ? [`- Largest trace-group movement: \`${dominantDelta.group}\` ${fmtDelta(dominantDelta.before, dominantDelta.after, 4)}, pointing at ${groupHint(dominantDelta.group)}.`] + : ["- No comparable trace-group movement was found."]), + "", + "## Frame Metrics", + "", + "| Metric | Before | After | Delta |", + "| --- | ---: | ---: | ---: |", + metricRow("fps_p50", beforeFrames.fps_p50, afterFrames.fps_p50, 2), + metricRow("fps_p95", beforeFrames.fps_p95, afterFrames.fps_p95, 2), + metricRow("frame_time_p50_ms", beforeFrames.frame_time_p50_ms, afterFrames.frame_time_p50_ms), + metricRow("frame_time_p95_ms", beforeFrames.frame_time_p95_ms, afterFrames.frame_time_p95_ms), + metricRow("frame_time_p99_ms", beforeFrames.frame_time_p99_ms, afterFrames.frame_time_p99_ms), + metricRow("frame_count", beforeFrames.frame_count, afterFrames.frame_count, 0), + metricRow("poly_count", beforeFrames.poly_count, afterFrames.poly_count, 0), + "", + "## Trace Groups", + "", + "| Group | Before ms/frame | After ms/frame | Delta |", + "| --- | ---: | ---: | ---: |", + ...groupNames.map((group) => metricRow(group, beforeGroups[group] ?? 0, afterGroups[group] ?? 0, 4)), + "", + ]; + + const markdown = `${lines.join("\n")}\n`; + const outFile = argValue(args, "markdown-out"); + if (outFile) writeFileSync(outFile, markdown); + process.stdout.write(markdown); +} + +function inferSummaryPath(cmd, args) { + const summaryOut = argValue(args, "summary-out"); + if (summaryOut) return resolve(summaryOut); + + if (cmd === "generic" || cmd === "capture") { + return resolve(argValue(args, "summary-out") || "chrome-trace-summary.json"); + } + + if (cmd === "polycss-motion" || cmd === "motion" || cmd === "polycss-buckets" || cmd === "buckets") { + const label = argValue(args, "label"); + if (!label) { + throw new Error("motion --report requires --label so the runner writes bench/results/