test(studio): gate playback cost with the Catalog open

A new step in the Studio timeline viewport job plays a small GSAP film with
the Catalog showing. It holds three work counts exactly through
perf-ceilings.json (Catalog elements, composited layers, eventful timeline
seeks per frame), and times main-thread CPU per frame against the base
branch's Studio built on the same runner, in alternating runs, failing above
1.15x. Chrome runs without vsync or a frame cap, so a 60 Hz runner measures
what a frame costs, not the display's pace. The catalog, its posters and GSAP
are served by the script, so the counts never follow the live registry.
This commit is contained in:
Miguel Ángel
2026-09-26 23:10:30 -04:00
parent 984ad28c7c
commit befdc993c0
6 changed files with 414 additions and 1 deletions
+62 -1
View File
@@ -144,6 +144,7 @@ jobs:
- "packages/studio/**"
- "packages/core/**"
- "packages/studio-server/**"
- "packages/player/**"
- "bun.lock"
- ".github/workflows/ci.yml"
gcp_beginframe:
@@ -763,7 +764,7 @@ jobs:
needs: [changes]
if: needs.changes.outputs.studio == 'true'
runs-on: ubuntu-latest
timeout-minutes: 18
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -786,6 +787,8 @@ jobs:
packages/studio/data/projects/timeline-virtualization
cp -R packages/studio/tests/e2e/fixtures/studio-open \
packages/studio/data/projects/studio-open
cp -R packages/studio/tests/e2e/fixtures/studio-playback \
packages/studio/data/projects/studio-playback
- name: Read the base branch's work-count ceilings
# A ceiling may not disappear, or rise without a new reason, against the branch this PR merges into.
if: github.event_name == 'pull_request'
@@ -797,6 +800,21 @@ jobs:
rm -f /tmp/base-perf-ceilings.json
echo "The base branch has no perf-ceilings.json yet"
fi
- name: Build the base branch's Studio for the playback timing
# The playback gate times this Studio against its base on the same runner, in
# alternating runs, so the runner's speed cancels out of the ratio.
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git worktree add --detach /tmp/hf-base FETCH_HEAD
cd /tmp/hf-base
bash scripts/ci/install-workspace-dependencies.sh
bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
bun run --cwd packages/core build
bun run --cwd packages/core build:hyperframes-runtime
mkdir -p packages/studio/data/projects
cp -R "$GITHUB_WORKSPACE/packages/studio/tests/e2e/fixtures/studio-playback" \
packages/studio/data/projects/studio-playback
- name: Run both arms of the timeline viewport gate
run: |
set -euo pipefail
@@ -893,6 +911,48 @@ jobs:
node packages/studio/tests/e2e/perf-ratchet.mjs check \
packages/studio/tests/e2e/perf-ceilings.json studio-open /tmp/studio-open-counts.json \
/tmp/base-perf-ceilings.json
- name: Count and time playback with the Catalog open
# Counts go through perf-ceilings.json like studio-open. Time is only ever a ratio
# against the base branch's Studio on this runner, so a slow runner cannot fail it.
# Both servers stay up; they sit idle while the other one is measured.
if: success() || failure()
run: |
set -uo pipefail
PIDS=()
trap 'kill "${PIDS[@]}" 2>/dev/null || true' EXIT
NODE_ENV=production bun run --cwd packages/studio dev -- --port 5316 --strictPort &
PIDS+=($!)
PORTS=(5316)
BASE_URL=""
if [[ -d /tmp/hf-base ]]; then
(cd /tmp/hf-base && NODE_ENV=production bun run --cwd packages/studio dev -- --port 5317 --strictPort) &
PIDS+=($!)
PORTS+=(5317)
BASE_URL="http://localhost:5317/#project/studio-playback"
fi
for port in "${PORTS[@]}"; do
for i in $(seq 1 60); do
if curl -sf "http://localhost:${port}/" >/dev/null 2>&1; then break; fi
sleep 1
done
if ! curl -sf "http://localhost:${port}/" >/dev/null 2>&1; then
echo "FAIL: studio dev server did not start on port ${port}"
exit 1
fi
done
STUDIO_URL="http://localhost:5316/#project/studio-playback" \
STUDIO_BASE_URL="$BASE_URL" \
node packages/studio/tests/e2e/studio-playback.mjs \
| tee /tmp/studio-playback.json
TIMING_STATUS=$?
node packages/studio/tests/e2e/perf-ratchet.mjs check \
packages/studio/tests/e2e/perf-ceilings.json studio-playback /tmp/studio-playback.json \
/tmp/base-perf-ceilings.json
COUNTS_STATUS=$?
if (( TIMING_STATUS != 0 || COUNTS_STATUS != 0 )); then
echo "FAIL: playback timing=${TIMING_STATUS}, counts=${COUNTS_STATUS}"
exit 1
fi
- name: Upload gate evidence
# The gate's whole output is machine-readable evidence, and a red run is
# exactly when someone needs to read it. Keep it on failure too.
@@ -903,6 +963,7 @@ jobs:
path: |
/tmp/timeline-gate-*.json
/tmp/studio-open-counts.json
/tmp/studio-playback.json
if-no-files-found: error
smoke-global-install:
@@ -0,0 +1,4 @@
# Studio playback fixture
A 12 s film of 24 GSAP-animated tiles and a title, with no media. `studio-playback.mjs` opens it,
closes the Compositions tab so the Catalog shows, plays it and records what each frame costs.
@@ -0,0 +1,8 @@
{
"$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
"paths": {
"blocks": "compositions",
"components": "compositions/components",
"assets": "assets"
}
}
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Studio playback fixture</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
html,
body {
width: 1920px;
height: 1080px;
margin: 0;
overflow: hidden;
background: #0f172a;
font-family: Arial, sans-serif;
}
.tile {
position: absolute;
width: 160px;
height: 160px;
border-radius: 24px;
background: #38bdf8;
}
.title {
position: absolute;
left: 120px;
top: 820px;
margin: 0;
font-size: 120px;
color: #fff;
}
</style>
</head>
<body>
<main
id="studio-playback"
data-hf-id="studio-playback"
data-composition-id="studio-playback"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="12"
data-fps="30"
>
<h1 class="title" data-hf-id="title">Playback</h1>
</main>
<script>
const root = document.getElementById("studio-playback");
const tl = gsap.timeline({ paused: true });
for (let i = 0; i < 24; i += 1) {
const tile = document.createElement("div");
tile.className = "tile";
tile.dataset.hfId = `tile-${i}`;
tile.style.left = `${120 + (i % 8) * 210}px`;
tile.style.top = `${120 + Math.floor(i / 8) * 210}px`;
root.appendChild(tile);
tl.to(
tile,
{
x: 60,
rotation: 180,
opacity: 0.4,
duration: 2,
repeat: 5,
yoyo: true,
ease: "sine.inOut",
},
i * 0.05,
);
}
tl.from(".title", { y: 80, opacity: 0, duration: 1 }, 0);
window.__timelines = { "studio-playback": tl };
</script>
</body>
</html>
@@ -10,5 +10,13 @@
"raised": {
"total.requests.thumbnail": "#4401 shows a cached frame-0 poster while the preview boots: a cache-only ask that misses on a fresh open, then a render of that poster for the next open (+2 requests)"
}
},
"studio-playback": {
"browser": "154",
"counts": {
"playing.catalogElements": 6878,
"playing.compositedLayers": 172,
"playing.maxSeeksPerFrame": 1
}
}
}
@@ -0,0 +1,257 @@
#!/usr/bin/env node
// Plays the studio-playback fixture with the Catalog open and prints evidence: exact work counts for
// perf-ratchet.mjs and main-thread CPU per frame. With STUDIO_BASE_URL (the base branch's Studio on this runner)
// it alternates runs of both and exits 1 when this build's median CPU per frame exceeds MAX_CPU_RATIO of the base's.
// Chrome runs without vsync or a frame cap, so a frame costs what the page makes it cost, even on a 60 Hz runner.
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import puppeteer from "puppeteer-core";
import { resolveChromeExecutable } from "./chrome-executable.mjs";
const HEAD_URL = process.env.STUDIO_URL;
const BASE_URL = process.env.STUDIO_BASE_URL;
const ROUNDS = Number(process.env.STUDIO_PLAYBACK_ROUNDS || 3);
const PLAY_MS = 4_000;
const MAX_CPU_RATIO = 1.15;
// A catalog the size of the real registry, served by this script so the counts never follow it.
const CATALOG_SIZE = 400;
const CATEGORIES = ["transitions", "vfx", "social", "data", "scenes", "captions", "effects"];
if (!HEAD_URL) {
console.error("STUDIO_URL is required and must point at the studio-playback fixture");
process.exit(2);
}
const executablePath = resolveChromeExecutable();
if (!executablePath) {
console.error("No Chrome executable found; set PUPPETEER_EXECUTABLE_PATH");
process.exit(2);
}
const gsapSource = readFileSync(
createRequire(import.meta.url).resolve("gsap/dist/gsap.min.js"),
"utf8",
);
const catalog = Array.from({ length: CATALOG_SIZE }, (_, i) => ({
name: `perf-block-${i}`,
type: "hyperframes:block",
title: `Block ${i}`,
description: `Catalog entry ${i} for the playback gate`,
tags: [CATEGORIES[i % CATEGORIES.length]],
duration: 5,
preview: { poster: `/__studio-playback/poster/${i}.svg` },
}));
const poster = (i) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="hsl(${(i * 37) % 360} 60% 40%)"/><text x="40" y="200" font-size="64" fill="#fff">${i}</text></svg>`;
/** A response for each fixture this script serves in place of the network, by URL. */
// fallow-ignore-next-line complexity
function fixtureResponse(url) {
if (url.pathname.endsWith("/api/registry/blocks")) {
return { contentType: "application/json", body: JSON.stringify(catalog) };
}
const posterMatch = /^\/__studio-playback\/poster\/(\d+)\.svg$/.exec(url.pathname);
if (posterMatch) return { contentType: "image/svg+xml", body: poster(Number(posterMatch[1])) };
const isGsap = url.hostname === "cdn.jsdelivr.net" && url.pathname.endsWith("/gsap.min.js");
return isGsap ? { contentType: "application/javascript", body: gsapSource } : null;
}
/** Serves the catalog, its posters and GSAP locally, and refuses every other off-origin request. */
async function serveFixtures(page, origin) {
await page.setRequestInterception(true);
page.on("request", (request) => {
const url = new URL(request.url());
const fixture = fixtureResponse(url);
if (fixture) return request.respond(fixture);
return url.origin === origin ? request.continue() : request.abort();
});
}
/** Counts, per composition animation frame, the eventful seeks of GSAP timelines. */
function instrumentPlayback() {
const player = document.querySelector("hyperframes-player");
const win = player.shadowRoot.querySelector("iframe").contentWindow;
const timelines = Object.values(win.__timelines).filter(
(tl) => typeof tl?.totalTime === "function",
);
if (timelines.length === 0) throw new Error("the composition registered no timeline to watch");
const state = (window.__studioPlayback = { on: false, frames: [], current: 0 });
// Studio wraps each timeline's own totalTime, so count on the instance, outside those wrappers.
for (const tl of timelines) {
const totalTime = tl.totalTime;
tl.totalTime = function (time, suppressEvents) {
const eventfulSeek = arguments.length > 0 && !suppressEvents;
if (eventfulSeek && state.on) state.current += 1;
return totalTime.apply(this, arguments);
};
}
const frame = () => {
if (state.on) state.frames.push(state.current);
state.current = 0;
win.requestAnimationFrame(frame);
};
win.requestAnimationFrame(frame);
}
/** The renderer main thread's complete events, in time order. */
function mainThreadEvents(traceEvents) {
const main = traceEvents.find(
(e) => e.ph === "M" && e.name === "thread_name" && e.args.name === "CrRendererMain",
);
return traceEvents
.filter((e) => e.pid === main.pid && e.tid === main.tid && e.ph === "X")
.sort((a, b) => a.ts - b.ts);
}
/** Main-thread CPU per produced frame: top-level task CPU between consecutive BeginMainFrames. */
// fallow-ignore-next-line complexity
function cpuPerFrameMs(traceEvents) {
const events = mainThreadEvents(traceEvents);
const frameStarts = events.filter((e) => e.name === "ProxyMain::BeginMainFrame").map((e) => e.ts);
const cpu = new Array(Math.max(0, frameStarts.length - 1)).fill(0);
let taskEnd = -1;
for (const event of events) {
if (event.ts < taskEnd) continue; // nested inside the previous top-level task
taskEnd = event.ts + event.dur;
const frame = frameStarts.findLastIndex((start) => start <= event.ts);
if (frame >= 0 && frame < cpu.length) cpu[frame] += (event.tdur ?? event.dur) / 1000;
}
return cpu;
}
const median = (values) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted.length ? sorted[Math.floor(sorted.length / 2)] : Number.NaN;
};
const round = (value) => Math.round(value * 100) / 100;
async function measure(url) {
const browser = await puppeteer.launch({
executablePath,
headless: true,
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu-vsync",
"--disable-frame-rate-limit",
"--window-size=1600,900",
],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 900 });
await serveFixtures(page, new URL(url).origin);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
await page.waitForFunction(
() => {
const play = document.querySelector('button[aria-label="Play"]');
return play instanceof HTMLButtonElement && !play.disabled;
},
{ timeout: 120_000, polling: 50 },
);
// #4529's setup: closing the Compositions tab leaves the Catalog showing.
await page.click('[aria-label="Close Compositions"]');
await page.waitForFunction(
() => document.querySelectorAll('img[src*="/__studio-playback/poster/"]').length > 0,
{ timeout: 30_000, polling: 100 },
);
// Posters below the panel's fold are lazy and never load, so wait for the first rows only.
await page.waitForFunction(
() =>
[...document.querySelectorAll('img[src*="/__studio-playback/poster/"]')]
.slice(0, 4)
.every((img) => img.complete),
{ timeout: 30_000, polling: 100 },
);
await page.evaluate(instrumentPlayback);
await page.mouse.move(0, 0);
await page.click('button[aria-label="Play"]');
await page.mouse.move(0, 0);
await page.waitForFunction(() => document.querySelector('button[aria-label="Pause"]'), {
timeout: 10_000,
});
await new Promise((resolve) => setTimeout(resolve, 500));
await page.tracing.start({ categories: ["devtools.timeline", "toplevel", "cc"] });
await page.evaluate(() => {
window.__studioPlayback.on = true;
});
await new Promise((resolve) => setTimeout(resolve, PLAY_MS));
const counts = await page.evaluate(() => {
window.__studioPlayback.on = false;
// The Catalog's own subtree: the nearest ancestor of its search box that holds its posters.
let panel = document.querySelector('input[aria-label="Search blocks"]');
while (panel && !panel.querySelector('img[src*="/__studio-playback/poster/"]')) {
panel = panel.parentElement;
}
return {
catalogElements: panel ? panel.querySelectorAll("*").length : -1,
seekFrames: window.__studioPlayback.frames,
};
});
const trace = JSON.parse(Buffer.from(await page.tracing.stop()).toString("utf8"));
// Layers are read paused: while playing, the film's own layers come and go with its tweens.
await page.click('button[aria-label="Pause"]');
await new Promise((resolve) => setTimeout(resolve, 500));
const cdp = await page.createCDPSession();
const layers = new Promise((resolve) =>
cdp.on("LayerTree.layerTreeDidChange", (event) => event.layers && resolve(event.layers)),
);
await cdp.send("LayerTree.enable");
const compositedLayers = (await layers).length;
const cpu = cpuPerFrameMs(trace.traceEvents ?? trace);
const seekFrames = counts.seekFrames.slice(1);
return {
browser: await browser.version(),
frames: cpu.length,
cpuMedianMs: round(median(cpu)),
workCounts: {
"playing.catalogElements": counts.catalogElements,
"playing.compositedLayers": compositedLayers,
"playing.maxSeeksPerFrame": seekFrames.length ? Math.max(...seekFrames) : -1,
},
};
} finally {
await browser.close();
}
}
const head = [];
const base = [];
for (let round_ = 0; round_ < ROUNDS; round_ += 1) {
head.push(await measure(HEAD_URL));
if (BASE_URL) base.push(await measure(BASE_URL));
}
// Counts must repeat exactly across runs of one build, or the ratchet would fail at random.
const countSets = new Set(head.map((run) => JSON.stringify(run.workCounts)));
if (countSets.size !== 1) {
console.error(`work counts differ between runs of one build: ${[...countSets].join(" vs ")}`);
process.exit(1);
}
const evidence = {
journey: "studio-playback",
browser: head[0].browser,
workCounts: head[0].workCounts,
cpuMedianMsPerRun: head.map((run) => run.cpuMedianMs),
framesPerRun: head.map((run) => run.frames),
};
let failed = false;
if (BASE_URL) {
const headMs = median(head.map((run) => run.cpuMedianMs));
const baseMs = median(base.map((run) => run.cpuMedianMs));
evidence.ab = {
headCpuMedianMs: headMs,
baseCpuMedianMs: baseMs,
baseCpuMedianMsPerRun: base.map((run) => run.cpuMedianMs),
baseWorkCounts: base[0].workCounts,
ratio: round(headMs / baseMs),
maxRatio: MAX_CPU_RATIO,
};
failed = headMs > baseMs * MAX_CPU_RATIO;
}
console.log(JSON.stringify(evidence, null, 2));
if (failed) {
console.error(
`FAIL main-thread CPU per frame ${evidence.ab.headCpuMedianMs} ms against the base's ${evidence.ab.baseCpuMedianMs} ms (x${evidence.ab.ratio}, limit x${MAX_CPU_RATIO})`,
);
process.exit(1);
}