Skip to content
Open
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
98 changes: 98 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
skills: ${{ steps.filter.outputs.skills }}
codex_plugin: ${{ steps.filter.outputs.codex_plugin }}
gcp_beginframe: ${{ steps.filter.outputs.gcp_beginframe }}
studio: ${{ steps.filter.outputs.studio }}
steps:
# Force git-based change detection instead of the pull_request REST API.
# The API path can fail the whole workflow on transient listFiles
Expand Down Expand Up @@ -77,6 +78,12 @@ jobs:
- "scripts/package-codex-plugin.mjs"
- "package.json"
- ".github/workflows/ci.yml"
studio:
- "packages/studio/**"
- "packages/core/**"
- "packages/studio-server/**"
- "bun.lock"
- ".github/workflows/ci.yml"
gcp_beginframe:
- "packages/gcp-cloud-run/Dockerfile"
- "packages/aws-lambda/scripts/probe-beginframe.ts"
Expand Down Expand Up @@ -496,6 +503,97 @@ jobs:

kill $SERVER_PID 2>/dev/null || true

studio-timeline-viewport:
name: "Studio: timeline viewport gate"
needs: [changes]
if: needs.changes.outputs.studio == 'true'
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- run: bun install --frozen-lockfile
# Same reason as studio-load-smoke: vite.config.ts is loaded by Node and
# resolves the workspace packages through their "node" export condition.
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
- run: bun run --cwd packages/core build
- run: bun run --cwd packages/core build:hyperframes-runtime
- name: Install the fixture as a Studio project
# Studio resolves projects from packages/studio/data/projects, which is
# gitignored. The fixture composition is tracked under tests/e2e, so
# copy it into place rather than committing a project directory.
run: |
mkdir -p packages/studio/data/projects
cp -R packages/studio/tests/e2e/fixtures/timeline-virtualization \
packages/studio/data/projects/timeline-virtualization
- name: Run both arms of the timeline viewport gate
run: |
set -euo pipefail

# Two servers, because row virtualization is read from import.meta.env
# at module load: one process cannot serve both builds.
bun run --cwd packages/studio dev -- --port 5313 --strictPort &
DEFAULT_PID=$!
VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=1 \
bun run --cwd packages/studio dev -- --port 5314 --strictPort &
VIRTUALIZED_PID=$!
trap 'kill $DEFAULT_PID $VIRTUALIZED_PID 2>/dev/null || true' EXIT

for i in $(seq 1 60); do
if curl -sf http://localhost:5313/ >/dev/null 2>&1 \
&& curl -sf http://localhost:5314/ >/dev/null 2>&1; then break; fi
sleep 1
done
if ! curl -sf http://localhost:5313/ >/dev/null 2>&1 \
|| ! curl -sf http://localhost:5314/ >/dev/null 2>&1; then
echo "FAIL: studio dev servers did not start"
exit 1
fi

# The default build first. It is the one users get, and the arm that
# caught the regression this gate exists for.
# Capture both statuses so either failure still leaves two evidence files.
DEFAULT_STATUS=0
STUDIO_URL="http://localhost:5313/#project/timeline-virtualization" \
TIMELINE_ROW_VIRTUALIZATION=off \
TIMELINE_ELEMENT_COUNT=1000 \
TIMELINE_TIER=ci \
node packages/studio/tests/e2e/timeline-virtualization.mjs \
| tee /tmp/timeline-gate-default.json \
|| DEFAULT_STATUS=$?

VIRTUALIZED_STATUS=0
STUDIO_URL="http://localhost:5314/#project/timeline-virtualization" \
TIMELINE_ROW_VIRTUALIZATION=on \
TIMELINE_ELEMENT_COUNT=50000 \
TIMELINE_TIER=ci \
node packages/studio/tests/e2e/timeline-virtualization.mjs \
| tee /tmp/timeline-gate-virtualized.json \
|| VIRTUALIZED_STATUS=$?

{
echo "### Timeline viewport gate"
echo "- Default arm exit: ${DEFAULT_STATUS}"
echo "- Virtualized arm exit: ${VIRTUALIZED_STATUS}"
} >> "$GITHUB_STEP_SUMMARY"

if (( DEFAULT_STATUS != 0 || VIRTUALIZED_STATUS != 0 )); then
echo "FAIL: default=${DEFAULT_STATUS}, virtualized=${VIRTUALIZED_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.
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: timeline-viewport-gate-evidence
path: /tmp/timeline-gate-*.json
if-no-files-found: error

smoke-global-install:
name: "Smoke: global install"
needs: [changes, build]
Expand Down
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@
"build": "vite build && tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
"test:watch": "vitest",
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts"
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
"test:timeline-default": "TIMELINE_ROW_VIRTUALIZATION=off TIMELINE_ELEMENT_COUNT=1000 node tests/e2e/timeline-virtualization.mjs"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
Expand All @@ -70,6 +72,7 @@
"@hyperframes/sdk": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-virtual": "^3.14.6",
"bpm-detective": "^2.0.5",
"dompurify": "^3.2.4",
"gsap": "^3.13.0",
Expand Down
5 changes: 4 additions & 1 deletion packages/studio/src/components/nle/NLEContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface NLEContextValue {
compositionLoading: boolean;
setCompositionLoading: (loading: boolean) => void;
timelineDisabled: boolean;
timelineSessionEpoch: number;
hasLoadedOnceRef: React.MutableRefObject<boolean>;
// preview composition size (for preview block drop)
previewCompositionSize: { width: number; height: number } | null;
Expand Down Expand Up @@ -103,7 +104,7 @@ export function NLEProvider({
// project would otherwise keep rendering (and re-fetching from) the old project
// after switching.
useEffect(() => {
usePlayerStore.getState().reset();
usePlayerStore.getState().beginTimelineSession(projectId);
useAssetPreviewStore.getState().clearPreviewAsset();
}, [projectId]);

Expand Down Expand Up @@ -289,6 +290,7 @@ export function NLEProvider({
setCompositionLoadingRaw(loading);
}, []);
const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
const timelineSessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);

useEffect(() => {
onCompositionLoadingChange?.(compositionLoading);
Expand Down Expand Up @@ -319,6 +321,7 @@ export function NLEProvider({
compositionLoading,
setCompositionLoading,
timelineDisabled,
timelineSessionEpoch,
hasLoadedOnceRef,
previewCompositionSize,
setPreviewCompositionSize,
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/components/nle/TimelinePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function TimelinePane({
persistTimelineH,
containerRef,
timelineDisabled,
timelineSessionEpoch,
} = useNLEContext();

// Move/resize/split come from the timeline edit context, not props — the
Expand Down Expand Up @@ -271,6 +272,7 @@ export function TimelinePane({
>
<div className="flex-shrink-0">{timelineToolbar}</div>
<Timeline
sessionEpoch={timelineSessionEpoch}
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
Expand Down
160 changes: 160 additions & 0 deletions packages/studio/src/hooks/useStudioTestHooks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../player/store/playerStore";
import {
createTimelinePerformanceFixture,
hasTimelinePerformanceFixtureLease,
setTimelinePerformanceFixtureLease,
type TimelinePerformanceFixtureProfile,
} from "../player/lib/timelinePerformanceFixture";
import { useStudioTestHooks } from "./useStudioTestHooks";

Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);

const PROFILES: readonly TimelinePerformanceFixtureProfile[] = [
"dense-short",
"long-overlap",
"keyframe-heavy-expanded",
"composition-heavy",
"remote-unsupported",
];

function Probe(): null {
useStudioTestHooks({
previewIframeRef: { current: null },
buildDomSelectionFromTarget: vi.fn(),
applyDomSelection: vi.fn(),
});
return null;
}

describe("timeline performance fixture", () => {
afterEach(() => {
setTimelinePerformanceFixtureLease(false);
window.__studioTest = undefined;
usePlayerStore.getState().reset();
});

it("generates the expected dense-short 50k distribution", () => {
const first = createTimelinePerformanceFixture({
elementCount: 50_000,
profile: "dense-short",
});

expect(first.summary).toEqual({
elementCount: 50_000,
profile: "dense-short",
duration: 120,
trackCount: 1_000,
keyframedElementCount: 0,
expandedElementCount: 0,
});
expect(new Set(first.elements.map((element) => element.track)).size).toBe(1_000);
const perTrack = new Map<number, number>();
for (const element of first.elements) {
perTrack.set(element.track, (perTrack.get(element.track) ?? 0) + 1);
}
expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128);
});

it.each(PROFILES)("generates an identical 50k %s fixture", (profile) => {
const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
expect(second).toEqual(first);
});

it.each(PROFILES)("builds the %s 1k scale profile", (profile) => {
const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile });
expect(fixture.elements).toHaveLength(1_000);
expect(fixture.summary.elementCount).toBe(1_000);
expect(fixture.summary.duration).toBeGreaterThan(0);
expect(new Set(fixture.elements.map((element) => element.key)).size).toBe(1_000);
if (profile === "keyframe-heavy-expanded") {
expect(fixture.keyframeCache.size).toBe(1_000);
expect(fixture.gsapAnimations.size).toBe(1_000);
expect(fixture.expandedClipIds.size).toBe(1_000);
}
});

it("publishes one dev-only loader that replaces fixture state atomically", () => {
const host = document.createElement("div");
const root = createRoot(host);
act(() => root.render(<Probe />));
const api = window.__studioTest;
expect(api).toBeDefined();
if (!api) throw new Error("Expected dev Studio test API");
let notifications = 0;
usePlayerStore.setState({
isPlaying: true,
requestedSeekTime: 42,
clipRevealRequest: { elementId: "stale", nonce: 7 },
clipManifest: [],
lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]),
});
const unsubscribe = usePlayerStore.subscribe(() => {
notifications += 1;
});

const summary = api.loadTimelinePerformanceFixture({
elementCount: 1_000,
profile: "keyframe-heavy-expanded",
});

expect(summary.elementCount).toBe(1_000);
expect(notifications).toBe(1);
expect(usePlayerStore.getState()).toMatchObject({
isPlaying: false,
requestedSeekTime: null,
clipRevealRequest: null,
clipManifest: null,
duration: 600,
timelineReady: true,
});
expect(usePlayerStore.getState().lintFindingsByElement.size).toBe(0);
expect(usePlayerStore.getState().elements).toHaveLength(1_000);
expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000);
expect(hasTimelinePerformanceFixtureLease()).toBe(true);

api.resetTimelinePerformanceFixture();
expect(notifications).toBe(2);
expect(usePlayerStore.getState()).toMatchObject({
isPlaying: false,
duration: 0,
timelineReady: false,
elements: [],
});
expect(hasTimelinePerformanceFixtureLease()).toBe(true);
unsubscribe();
act(() => root.unmount());
expect(window.__studioTest).toBeUndefined();
// The lease outlives the effect on purpose. Loading a fixture changes the
// player state this effect depends on, so the effect tears down right after
// the loader runs; releasing the lease there let live iframe discovery
// overwrite the fixture before anything could measure it.
expect(hasTimelinePerformanceFixtureLease()).toBe(true);
});

it("does not mutate state when the fixture request is invalid", () => {
const host = document.createElement("div");
const root = createRoot(host);
act(() => root.render(<Probe />));
const api = window.__studioTest;
if (!api) throw new Error("Expected dev Studio test API");
const before = usePlayerStore.getState().elements;

expect(() =>
Reflect.apply(api.loadTimelinePerformanceFixture, api, [
{ elementCount: 999, profile: "dense-short" },
]),
).toThrow("elementCount must be 1000 or 50000");
expect(usePlayerStore.getState().elements).toBe(before);
expect(() =>
Reflect.apply(api.loadTimelinePerformanceFixture, api, [
{ elementCount: 1_000, profile: "constructor" },
]),
).toThrow("Unknown timeline performance fixture profile");
act(() => root.unmount());
});
});
Loading
Loading