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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ jobs:
- run: bun run --cwd packages/core build:hyperframes-runtime
- name: Start studio and check for runtime errors
run: |
# Start the studio dev server in the background
# Start the studio Vite dev server (fast — no bundle step)
bun run --filter '@hyperframes/studio' dev -- --port 5199 &
SERVER_PID=$!

Expand All @@ -283,8 +283,8 @@ jobs:
exit 1
fi

# Load the studio in headless Chrome and capture console errors
# puppeteer is a dependency of @hyperframes/producer; resolve from there
# Load the studio in headless Chrome with API mocking to trigger
# the full splash→main transition (catches hooks-after-early-return bugs)
cd packages/producer
node --input-type=module <<'SMOKE_EOF'
import puppeteer from "puppeteer";
Expand All @@ -298,18 +298,68 @@ jobs:
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
await page.goto("http://localhost:5199/", { waitUntil: "networkidle0", timeout: 30000 });

// Mock the project API so the studio transitions past the splash screen.
// Without this, useServerConnection stays in "waiting" and the full React
// tree (with all hooks) never renders — missing hooks-order violations.
const COMP_HTML = '<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="1" data-start="0"><div class="clip" data-start="0" data-duration="1">Test</div></div>';
await page.setRequestInterception(true);
page.on("request", (req) => {
const url = req.url();
if (url.includes("/api/projects") && !url.includes("/files") && !url.includes("/preview") && !url.includes("/gsap")) {
req.respond({
status: 200,
contentType: "application/json",
body: JSON.stringify({ projects: [{ id: "smoke-test" }] }),
});
} else if (url.includes("/api/") && url.includes("/files")) {
req.respond({
status: 200,
contentType: "application/json",
body: JSON.stringify({ files: [{ path: "index.html", type: "file" }] }),
});
} else if (url.includes("/api/") && url.includes("/preview")) {
req.respond({ status: 200, contentType: "text/html", body: COMP_HTML });
} else if (url.includes("/api/")) {
req.respond({ status: 200, contentType: "application/json", body: JSON.stringify({}) });
} else {
req.continue();
}
});

await page.goto("http://localhost:5199/#project=smoke-test", {
waitUntil: "networkidle0",
timeout: 30000,
});
// Wait for React to render past splash into the full studio UI
await new Promise((r) => setTimeout(r, 3000));

// Check for React error boundary (catches hooks violations, render crashes)
const errorBoundary = await page.evaluate(() => {
const text = document.body.innerText;
if (text.includes("Something went wrong")) return text;
return null;
});
if (errorBoundary) {
errors.push("React error boundary triggered: " + errorBoundary);
}
await browser.close();
// Filter expected noise from mock endpoints
const fatal = errors.filter(
(e) => !e.includes("favicon") && !e.includes("ERR_CONNECTION_REFUSED"),
(e) =>
!e.includes("favicon") &&
!e.includes("ERR_CONNECTION_REFUSED") &&
!e.includes("Failed to fetch") &&
!e.includes("is not iterable") &&
!e.includes("Cannot read properties of undefined") &&
!e.includes("Cannot read properties of null"),
);
if (fatal.length > 0) {
console.error("FAIL: studio had runtime errors on load:");
console.error("FAIL: studio had runtime errors:");
for (const e of fatal) console.error(" •", e);
process.exit(1);
}
console.log("PASS: studio loaded without runtime errors");
console.log("PASS: studio loaded and transitioned without runtime errors");
SMOKE_EOF

kill $SERVER_PID 2>/dev/null || true
Expand Down
Binary file modified packages/producer/tests/webm-transparency/output/output.webm
Binary file not shown.
4 changes: 2 additions & 2 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,6 @@ export function StudioApp() {
timelineVisible,
toggleTimelineVisibility,
});
if (resolving || waitingForServer || !projectId)
return <StudioSplash waiting={waitingForServer} />;
const timelineToolbar = useMemo(
() => (
<TimelineToolbar
Expand All @@ -464,6 +462,8 @@ export function StudioApp() {
),
[toggleTimelineVisibility, domEditSession, timelineEditing.handleTimelineElementSplit],
);
if (resolving || waitingForServer || !projectId)
return <StudioSplash waiting={waitingForServer} />;
return (
<StudioShellProvider value={studioCtxValue}>
<StudioPlaybackProvider value={studioCtxValue}>
Expand Down
7 changes: 2 additions & 5 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type DomEditSelection } from "./domEditing";
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
import {
type BlockedMoveState,
type DomEditGroupPathOffsetCommit,
type FocusableDomEditOverlay,
type GestureState,
type GroupGestureState,
Expand All @@ -27,11 +28,7 @@ export {
resolveDomEditResizeGesture,
resolveDomEditRotationGesture,
} from "./domEditOverlayGestures";

export interface DomEditGroupPathOffsetCommit {
selection: DomEditSelection;
next: { x: number; y: number };
}
export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";

interface DomEditOverlayProps {
iframeRef: RefObject<HTMLIFrameElement | null>;
Expand Down
55 changes: 54 additions & 1 deletion packages/studio/src/components/editor/domEditOverlayGestures.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { RefObject } from "react";
import type { DomEditSelection } from "./domEditing";
import type {
StudioBoxSizeSnapshot,
StudioPathOffsetSnapshot,
StudioRotationSnapshot,
} from "./manualEdits";
import type { ManualOffsetDragMember } from "./manualOffsetDrag";
import type { GroupOverlayItem } from "./domEditOverlayGeometry";
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
import type { SnapContext } from "./snapTargetCollection";
import type { SnapGuidesState } from "./SnapGuideOverlay";

export type GestureKind = "drag" | "resize" | "rotate";

Expand Down Expand Up @@ -143,3 +145,54 @@ export function resolveDomEditRotationGesture(input: {
export function hasDomEditRotationChanged(initialAngle: number, nextAngle: number): boolean {
return Math.abs(nextAngle - initialAngle) >= ROTATION_COMMIT_EPSILON_DEGREES;
}

// ── Shared types for DomEditOverlay gesture wiring ──
// These live here (rather than in DomEditOverlay.tsx or useDomEditOverlayGestures.ts)
// to break circular imports between those files.

export interface DomEditGroupPathOffsetCommit {
selection: DomEditSelection;
next: { x: number; y: number };
}

// Refs are stable across renders; values are read via .current.
export type UseDomEditOverlayGesturesOptions = {
overlayRef: RefObject<HTMLDivElement | null>;
iframeRef: RefObject<HTMLIFrameElement | null>;
boxRef: RefObject<HTMLDivElement | null>;
selectionRef: RefObject<DomEditSelection | null>;
overlayRectRef: RefObject<OverlayRect | null>;
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
gestureRef: RefObject<GestureState | null>;
groupGestureRef: RefObject<GroupGestureState | null>;
blockedMoveRef: RefObject<BlockedMoveState | null>;
rafPausedRef: RefObject<boolean>;
suppressNextBoxClickRef: RefObject<boolean>;
setOverlayRect: (next: OverlayRect | null) => void;
setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
onManualDragStartRef: RefObject<(() => void) | undefined>;
onPathOffsetCommitRef: RefObject<
(s: DomEditSelection, n: { x: number; y: number }) => Promise<void> | void
>;
onGroupPathOffsetCommitRef: RefObject<
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
>;
onBoxSizeCommitRef: RefObject<
(s: DomEditSelection, n: { width: number; height: number }) => Promise<void> | void
>;
onRotationCommitRef: RefObject<
(s: DomEditSelection, n: { angle: number }) => Promise<void> | void
>;
onCanvasPointerMoveRef: RefObject<
(
e: React.PointerEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>
>;
onCanvasMouseDown: (
e: React.MouseEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => void;
snapGuidesRef: RefObject<SnapGuidesState | null>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ import {
filterNestedDomEditGroupItems,
selectionCacheKey,
} from "./domEditOverlayGeometry";
import { type GestureKind, type GestureState } from "./domEditOverlayGestures";
import type { UseDomEditOverlayGesturesOptions } from "./useDomEditOverlayGestures";
import {
type GestureKind,
type GestureState,
type UseDomEditOverlayGesturesOptions,
} from "./domEditOverlayGestures";
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";

export function startGroupDrag(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,14 @@ import {
} from "./domEditOverlayGeometry";
import {
BLOCKED_MOVE_THRESHOLD_PX,
type BlockedMoveState,
type GestureKind,
type GestureState,
type GroupGestureState,
type UseDomEditOverlayGesturesOptions,
hasDomEditRotationChanged,
resolveDomEditResizeGesture,
resolveDomEditRotationGesture,
} from "./domEditOverlayGestures";
import type { DomEditGroupPathOffsetCommit } from "./DomEditOverlay";
import {
startGesture as _startGesture,
startGroupDrag as _startGroupDrag,
Expand All @@ -52,50 +51,6 @@ import {
resolveEquidistanceGuides,
SNAP_THRESHOLD_PX,
} from "./snapEngine";
import type { SnapGuidesState } from "./SnapGuideOverlay";

// Refs are stable across renders; values are read via .current.
export type UseDomEditOverlayGesturesOptions = {
overlayRef: RefObject<HTMLDivElement | null>;
iframeRef: RefObject<HTMLIFrameElement | null>;
boxRef: RefObject<HTMLDivElement | null>;
selectionRef: RefObject<DomEditSelection | null>;
overlayRectRef: RefObject<OverlayRect | null>;
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
gestureRef: RefObject<GestureState | null>;
groupGestureRef: RefObject<GroupGestureState | null>;
blockedMoveRef: RefObject<BlockedMoveState | null>;
rafPausedRef: RefObject<boolean>;
suppressNextBoxClickRef: RefObject<boolean>;
setOverlayRect: (next: OverlayRect | null) => void;
setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
onManualDragStartRef: RefObject<(() => void) | undefined>;
onPathOffsetCommitRef: RefObject<
(s: DomEditSelection, n: { x: number; y: number }) => Promise<void> | void
>;
onGroupPathOffsetCommitRef: RefObject<
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
>;
onBoxSizeCommitRef: RefObject<
(s: DomEditSelection, n: { width: number; height: number }) => Promise<void> | void
>;
onRotationCommitRef: RefObject<
(s: DomEditSelection, n: { angle: number }) => Promise<void> | void
>;
onCanvasPointerMoveRef: RefObject<
(
e: React.PointerEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>
>;
onCanvasMouseDown: (
e: React.MouseEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => void;
snapGuidesRef: RefObject<SnapGuidesState | null>;
};

export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
const setDraftOverlayRect = (next: OverlayRect) => {
opts.setOverlayRect(next);
Expand Down
14 changes: 14 additions & 0 deletions packages/studio/src/hooks/domEditCommitTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { DomEditSelection } from "../components/editor/domEditing";
import type { PatchOperation } from "../utils/sourcePatcher";

export type PersistDomEditOperations = (
selection: DomEditSelection,
operations: PatchOperation[],
options?: {
label?: string;
coalesceKey?: string;
skipRefresh?: boolean;
prepareContent?: (html: string, sourceFile: string) => string;
shouldSave?: () => boolean;
},
) => Promise<void>;
4 changes: 2 additions & 2 deletions packages/studio/src/hooks/timelineEditingHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { TimelineElement } from "../player";
import type { TimelineElement } from "../player/store/playerStore";
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory";

// ── Types ──

interface RecordEditInput {
export interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
Expand Down
15 changes: 3 additions & 12 deletions packages/studio/src/hooks/useDomEditCommits.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useCallback, useRef } from "react";
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
import { FONT_EXT } from "../utils/mediaTypes";
import type { PatchOperation } from "../utils/sourcePatcher";

import { trackStudioEvent } from "../utils/studioTelemetry";
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
import type { PersistDomEditOperations } from "./domEditCommitTypes";
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
import { useDomEditTextCommits } from "./useDomEditTextCommits";
import { useDomGeometryCommits } from "./useDomGeometryCommits";
Expand Down Expand Up @@ -48,17 +49,7 @@ interface RecordEditInput {
files: Record<string, { before: string; after: string }>;
}

export type PersistDomEditOperations = (
selection: DomEditSelection,
operations: PatchOperation[],
options?: {
label?: string;
coalesceKey?: string;
skipRefresh?: boolean;
prepareContent?: (html: string, sourceFile: string) => string;
shouldSave?: () => boolean;
},
) => Promise<void>;
export type { PersistDomEditOperations } from "./domEditCommitTypes";

export interface UseDomEditCommitsParams {
activeCompPath: string | null;
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
import type { PatchOperation } from "../utils/sourcePatcher";
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
import type { PersistDomEditOperations } from "./useDomEditCommits";
import type { PersistDomEditOperations } from "./domEditCommitTypes";

interface UseDomEditPositionPatchCommitParams {
activeCompPath: string | null;
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/hooks/useDomEditTextCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
type DomEditSelection,
} from "../components/editor/domEditing";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { PersistDomEditOperations } from "./useDomEditCommits";
import type { PersistDomEditOperations } from "./domEditCommitTypes";

// ── Types ──

Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/hooks/useRazorSplit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
readFileContent,
isSplitTimeWithinBounds,
} from "../utils/timelineElementSplit";
import type { RecordEditInput } from "./useTimelineEditing";
import type { RecordEditInput } from "./timelineEditingHelpers";

interface UseRazorSplitOptions {
projectId: string | null;
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/utils/clipboardPayload.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./studioHelpers";
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";

const CLIPBOARD_MARKER = "hyperframes-clipboard:v1";

Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/utils/compositionPatterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Matches the opening tag of a composition root element (e.g. `<div data-composition-id="main">`). */
export const COMPOSITION_ROOT_OPEN_TAG_RE = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
Loading
Loading