Skip to content

Commit 2a8fc3b

Browse files
feat(studio): enable timeline virtualization by default (heygen-com#2926)
* feat(studio): enable timeline virtualization by default * fix(ci): measure timeline performance in production React
1 parent 7a0b6ea commit 2a8fc3b

13 files changed

Lines changed: 203 additions & 69 deletions

.github/workflows/ci.yml

Lines changed: 58 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -533,55 +533,72 @@ jobs:
533533
run: |
534534
set -euo pipefail
535535
536-
# Two servers, because row virtualization is read from import.meta.env
537-
# at module load: one process cannot serve both builds.
538-
bun run --cwd packages/studio dev -- --port 5313 --strictPort &
539-
DEFAULT_PID=$!
540-
VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=1 \
541-
bun run --cwd packages/studio dev -- --port 5314 --strictPort &
542-
VIRTUALIZED_PID=$!
543-
trap 'kill $DEFAULT_PID $VIRTUALIZED_PID 2>/dev/null || true' EXIT
544-
545-
for i in $(seq 1 60); do
546-
if curl -sf http://localhost:5313/ >/dev/null 2>&1 \
547-
&& curl -sf http://localhost:5314/ >/dev/null 2>&1; then break; fi
548-
sleep 1
549-
done
550-
if ! curl -sf http://localhost:5313/ >/dev/null 2>&1 \
551-
|| ! curl -sf http://localhost:5314/ >/dev/null 2>&1; then
552-
echo "FAIL: studio dev servers did not start"
553-
exit 1
536+
SERVER_PID=""
537+
stop_server() {
538+
if [[ -n "$SERVER_PID" ]]; then
539+
kill "$SERVER_PID" 2>/dev/null || true
540+
wait "$SERVER_PID" 2>/dev/null || true
541+
SERVER_PID=""
542+
fi
543+
}
544+
wait_for_server() {
545+
local port="$1"
546+
for i in $(seq 1 60); do
547+
if curl -sf "http://localhost:${port}/" >/dev/null 2>&1; then return 0; fi
548+
sleep 1
549+
done
550+
echo "FAIL: studio dev server did not start on port ${port}"
551+
return 1
552+
}
553+
trap stop_server EXIT
554+
555+
# Run one server at a time so the measured browser never competes with
556+
# a second Vite module graph on the shared runner. The development
557+
# server supplies the fixture API; production React matches shipped
558+
# rendering behavior, and the gate asserts that runtime before timing.
559+
NODE_ENV=production \
560+
bun run --cwd packages/studio dev -- --port 5313 --strictPort &
561+
SERVER_PID=$!
562+
DEFAULT_STATUS=0
563+
if wait_for_server 5313; then
564+
STUDIO_URL="http://localhost:5313/#project/timeline-virtualization" \
565+
TIMELINE_ROW_VIRTUALIZATION=on \
566+
TIMELINE_ELEMENT_COUNT=50000 \
567+
TIMELINE_TIER=ci \
568+
node packages/studio/tests/e2e/timeline-virtualization.mjs \
569+
| tee /tmp/timeline-gate-default.json \
570+
|| DEFAULT_STATUS=$?
571+
else
572+
DEFAULT_STATUS=1
554573
fi
574+
stop_server
555575
556-
# The default build first. It is the one users get, and the arm that
557-
# caught the regression this gate exists for.
558-
# Capture both statuses so either failure still leaves two evidence files.
559-
DEFAULT_STATUS=0
560-
STUDIO_URL="http://localhost:5313/#project/timeline-virtualization" \
561-
TIMELINE_ROW_VIRTUALIZATION=off \
562-
TIMELINE_ELEMENT_COUNT=1000 \
563-
TIMELINE_TIER=ci \
564-
node packages/studio/tests/e2e/timeline-virtualization.mjs \
565-
| tee /tmp/timeline-gate-default.json \
566-
|| DEFAULT_STATUS=$?
567-
568-
VIRTUALIZED_STATUS=0
569-
STUDIO_URL="http://localhost:5314/#project/timeline-virtualization" \
570-
TIMELINE_ROW_VIRTUALIZATION=on \
571-
TIMELINE_ELEMENT_COUNT=50000 \
572-
TIMELINE_TIER=ci \
573-
node packages/studio/tests/e2e/timeline-virtualization.mjs \
574-
| tee /tmp/timeline-gate-virtualized.json \
575-
|| VIRTUALIZED_STATUS=$?
576+
NODE_ENV=production \
577+
VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=0 \
578+
bun run --cwd packages/studio dev -- --port 5314 --strictPort &
579+
SERVER_PID=$!
580+
DISABLED_STATUS=0
581+
if wait_for_server 5314; then
582+
STUDIO_URL="http://localhost:5314/#project/timeline-virtualization" \
583+
TIMELINE_ROW_VIRTUALIZATION=off \
584+
TIMELINE_ELEMENT_COUNT=1000 \
585+
TIMELINE_TIER=ci \
586+
node packages/studio/tests/e2e/timeline-virtualization.mjs \
587+
| tee /tmp/timeline-gate-disabled.json \
588+
|| DISABLED_STATUS=$?
589+
else
590+
DISABLED_STATUS=1
591+
fi
592+
stop_server
576593
577594
{
578595
echo "### Timeline viewport gate"
579596
echo "- Default arm exit: ${DEFAULT_STATUS}"
580-
echo "- Virtualized arm exit: ${VIRTUALIZED_STATUS}"
597+
echo "- Explicitly disabled arm exit: ${DISABLED_STATUS}"
581598
} >> "$GITHUB_STEP_SUMMARY"
582599
583-
if (( DEFAULT_STATUS != 0 || VIRTUALIZED_STATUS != 0 )); then
584-
echo "FAIL: default=${DEFAULT_STATUS}, virtualized=${VIRTUALIZED_STATUS}"
600+
if (( DEFAULT_STATUS != 0 || DISABLED_STATUS != 0 )); then
601+
echo "FAIL: default=${DEFAULT_STATUS}, disabled=${DISABLED_STATUS}"
585602
exit 1
586603
fi
587604
- name: Upload gate evidence

packages/studio/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
5353
"test:watch": "vitest",
5454
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
55-
"test:timeline-default": "TIMELINE_ROW_VIRTUALIZATION=off TIMELINE_ELEMENT_COUNT=1000 node tests/e2e/timeline-virtualization.mjs"
55+
"test:timeline-default": "bun run test:timeline-virtualization"
5656
},
5757
"dependencies": {
5858
"@codemirror/autocomplete": "^6.20.1",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
afterEach(() => {
4+
vi.unstubAllEnvs();
5+
vi.resetModules();
6+
});
7+
8+
describe("Studio test mode", () => {
9+
it("exposes hooks in the normal development runtime", async () => {
10+
const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode");
11+
12+
expect(STUDIO_RUNTIME_MODE).toBe("development");
13+
expect(STUDIO_TEST_HOOKS_ENABLED).toBe(true);
14+
});
15+
16+
it("keeps hooks on the development server while it uses production React", async () => {
17+
vi.stubEnv("DEV", false);
18+
vi.stubEnv("MODE", "development");
19+
20+
const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode");
21+
22+
expect(STUDIO_RUNTIME_MODE).toBe("production");
23+
expect(STUDIO_TEST_HOOKS_ENABLED).toBe(true);
24+
});
25+
26+
it("keeps test hooks out of an ordinary production build", async () => {
27+
vi.stubEnv("DEV", false);
28+
vi.stubEnv("MODE", "production");
29+
30+
const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode");
31+
32+
expect(STUDIO_RUNTIME_MODE).toBe("production");
33+
expect(STUDIO_TEST_HOOKS_ENABLED).toBe(false);
34+
});
35+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export type StudioRuntimeMode = "development" | "production";
2+
3+
function readStudioImportMetaEnv(): ImportMetaEnv | undefined {
4+
try {
5+
return import.meta.env;
6+
} catch {
7+
return undefined;
8+
}
9+
}
10+
11+
const studioImportMetaEnv = readStudioImportMetaEnv();
12+
13+
/**
14+
* Test hooks belong to Vite's development server, even when that server uses
15+
* production React for performance measurement. A production build has neither
16+
* DEV nor the development server mode, so the API stays out of shipped assets.
17+
*/
18+
export const STUDIO_RUNTIME_MODE: StudioRuntimeMode = studioImportMetaEnv?.DEV
19+
? "development"
20+
: "production";
21+
22+
export const STUDIO_TEST_HOOKS_ENABLED =
23+
studioImportMetaEnv?.DEV === true || studioImportMetaEnv?.MODE === "development";

packages/studio/src/hooks/useStudioTestHooks.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ describe("timeline performance fixture", () => {
8585
const api = window.__studioTest;
8686
expect(api).toBeDefined();
8787
if (!api) throw new Error("Expected dev Studio test API");
88+
expect(api.runtimeMode).toBe("development");
8889
let notifications = 0;
8990
usePlayerStore.setState({
9091
isPlaying: true,

packages/studio/src/hooks/useStudioTestHooks.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type TimelinePerformanceFixtureSummary,
1313
} from "../player/lib/timelinePerformanceFixture";
1414
import { TIMELINE_VIEWPORT_BUDGETS } from "../player/lib/timelineViewportBudgets";
15+
import { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } from "./studioTestMode";
1516

1617
interface StudioTestHookDeps {
1718
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -23,6 +24,7 @@ interface StudioTestHookDeps {
2324
}
2425

2526
interface StudioTestApi {
27+
runtimeMode: typeof STUDIO_RUNTIME_MODE;
2628
selectByDomId: (id: string) => Promise<boolean>;
2729
loadTimelinePerformanceFixture: (
2830
spec: TimelinePerformanceFixtureSpec,
@@ -54,14 +56,9 @@ export function useStudioTestHooks({
5456
}: StudioTestHookDeps): void {
5557
// eslint-disable-next-line no-restricted-syntax
5658
useEffect(() => {
57-
let isDev = false;
58-
try {
59-
isDev = import.meta.env.DEV === true;
60-
} catch {
61-
isDev = false;
62-
}
63-
if (!isDev || typeof window === "undefined") return;
59+
if (!STUDIO_TEST_HOOKS_ENABLED || typeof window === "undefined") return;
6460
const api: StudioTestApi = {
61+
runtimeMode: STUDIO_RUNTIME_MODE,
6562
selectByDomId: async (id: string): Promise<boolean> => {
6663
const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null;
6764
if (!element) return false;

packages/studio/src/player/components/Timeline.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ import { formatTime } from "../lib/time";
4141
import { usePlayerStore } from "../store/playerStore";
4242
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
4343

44+
vi.mock("./timelineRowVirtualizationFlag", () => ({
45+
STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED: false,
46+
}));
47+
4448
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
4549

4650
afterEach(() => {

packages/studio/src/player/components/Timeline.virtualization.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,9 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
307307
});
308308

309309
/**
310-
* The flag-off build is the one users get today. It mounts every clip, so the
311-
* scroll-time concessions windowing makes are pure cost there: this block pins
312-
* the timeline to doing no per-frame work at all while a gesture runs.
310+
* The rollback build mounts every clip, so the scroll-time concessions
311+
* windowing makes are pure cost there. This block pins that explicit fallback
312+
* to doing no per-frame work while a gesture runs.
313313
*/
314314
describe("Timeline without row virtualization", { timeout: 30_000 }, () => {
315315
async function renderUnvirtualizedTimeline() {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
afterEach(() => {
4+
vi.unstubAllEnvs();
5+
vi.resetModules();
6+
});
7+
8+
describe("timeline row virtualization flag", () => {
9+
it("enables virtualization by default", async () => {
10+
const { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } =
11+
await import("./timelineRowVirtualizationFlag");
12+
13+
expect(STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED).toBe(true);
14+
});
15+
16+
it("keeps an explicit rollback path", async () => {
17+
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "0");
18+
const { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } =
19+
await import("./timelineRowVirtualizationFlag");
20+
21+
expect(STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED).toBe(false);
22+
});
23+
});
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
/**
2-
* Row virtualization opt-in. Disabled until horizontal windowing and stable
3-
* gesture lifetime land.
2+
* Row virtualization is the product default. Setting the environment flag to
3+
* "0" keeps one explicit rollback path for comparisons and emergencies.
44
*
55
* It lives in its own module so the scroll-viewport hook can read it without
66
* importing the virtualization hook that already imports the viewport snapshot
77
* type back, which would close an import cycle.
88
*/
99
export const STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED =
10-
import.meta.env.DEV === true &&
11-
import.meta.env.VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED === "1";
10+
import.meta.env.VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED !== "0";

0 commit comments

Comments
 (0)