Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export function StudioApp() {
reloadPreview: () => setRefreshKey((k) => k + 1),
pendingTimelineEditPathRef,
});
const sdkSession = useSdkSession(projectId, activeCompPath);
const timelineEditing = useTimelineEditing({
projectId,
activeCompPath,
Expand All @@ -188,6 +189,7 @@ export function StudioApp() {
pendingTimelineEditPathRef,
uploadProjectFiles: fileManager.uploadProjectFiles,
isRecordingRef: isGestureRecordingRef,
sdkSession,
});
const {
activeBlockParams,
Expand Down Expand Up @@ -267,7 +269,6 @@ export function StudioApp() {
() => leftSidebarRef.current?.getTab() ?? "compositions",
[],
);
const sdkSession = useSdkSession(projectId, activeCompPath);
const domEditSession = useDomEditSession({
projectId,
activeCompPath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,13 @@ export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;

// Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK
// session alongside the server patch path and logs mismatches via telemetry.
// Default false in production; enable via VITE_STUDIO_SDK_SHADOW_ENABLED=true.
// Default on: server stays authoritative (no user-visible change), so we want
// the sdk_shadow_dispatch parity signal from all traffic. Disable via
// VITE_STUDIO_SDK_SHADOW_ENABLED=false.
export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_SDK_SHADOW_ENABLED"],
false,
true,
);

export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
3 changes: 3 additions & 0 deletions packages/studio/src/hooks/gsapScriptCommitTypes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { EditHistoryKind } from "../utils/editHistory";

Expand Down Expand Up @@ -55,4 +56,6 @@ export interface GsapScriptCommitsParams {
onCacheInvalidate: () => void;
onFileContentChanged?: (path: string, content: string) => void;
showToast: (message: string, tone?: "error" | "info") => void;
/** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
sdkSession?: Composition | null;
}
4 changes: 4 additions & 0 deletions packages/studio/src/hooks/useDomEditCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export interface UseDomEditCommitsParams {
) => Promise<DomEditSelection | null>;
/** Stage 7 Step 3b: called after a successful server-side element patch. */
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
/** Stage 7 Step 3b: called after a successful server-side element delete. */
onElementDeleted?: (selection: DomEditSelection) => void;
}

export function useDomEditCommits({
Expand All @@ -94,6 +96,7 @@ export function useDomEditCommits({
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
onDomEditPersisted,
onElementDeleted,
}: UseDomEditCommitsParams) {
const resolveImportedFontAsset = useCallback(
(fontFamilyValue: string): ImportedFontAsset | null => {
Expand Down Expand Up @@ -290,6 +293,7 @@ export function useDomEditCommits({
reloadPreview,
clearDomSelection,
commitPositionPatchToHtml,
onElementDeleted,
});

return {
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/hooks/useDomEditSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
import { useDomSelection } from "./useDomSelection";
import { usePreviewInteraction } from "./usePreviewInteraction";
import { useDomEditCommits } from "./useDomEditCommits";
import { runShadowDispatch } from "../utils/sdkShadow";
import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow";
import { useGsapScriptCommits } from "./useGsapScriptCommits";
import { useGsapCacheVersion } from "./useGsapTweenCache";
import { useDomEditWiring } from "./useDomEditWiring";
Expand Down Expand Up @@ -194,6 +194,7 @@ export function useDomEditSession({
onCacheInvalidate: bumpGsapCache,
onFileContentChanged: updateEditingFileContent,
showToast,
sdkSession,
});

// ── DOM commit handlers ──
Expand Down Expand Up @@ -235,6 +236,7 @@ export function useDomEditSession({
onDomEditPersisted: sdkSession
? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
: undefined,
onElementDeleted: sdkSession ? (sel) => runShadowDelete(sdkSession, sel.hfId) : undefined,
});

// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
Expand Down
5 changes: 5 additions & 0 deletions packages/studio/src/hooks/useElementLifecycleOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ interface UseElementLifecycleOpsParams {
patches: PatchOperation[],
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
) => Promise<void>;
/** Stage 7 Step 3b: called after a successful server-side element delete (shadow). */
onElementDeleted?: (selection: DomEditSelection) => void;
}

export function useElementLifecycleOps({
Expand All @@ -43,6 +45,7 @@ export function useElementLifecycleOps({
reloadPreview,
clearDomSelection,
commitPositionPatchToHtml,
onElementDeleted,
}: UseElementLifecycleOpsParams) {
// fallow-ignore-next-line complexity
const handleDomEditElementDelete = useCallback(
Expand Down Expand Up @@ -103,6 +106,7 @@ export function useElementLifecycleOps({
clearDomSelection();
usePlayerStore.getState().setSelectedElementId(null);
reloadPreview();
onElementDeleted?.(selection);
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete element";
Expand All @@ -114,6 +118,7 @@ export function useElementLifecycleOps({
clearDomSelection,
domEditSaveTimestampRef,
editHistory.recordEdit,
onElementDeleted,
projectIdRef,
reloadPreview,
showToast,
Expand Down
24 changes: 23 additions & 1 deletion packages/studio/src/hooks/useGsapAnimationOps.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useCallback } from "react";
import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { roundTo3 } from "../utils/rounding";
import { runShadowGsapTween } from "../utils/sdkShadow";
import {
assignGsapTargetAutoIdIfNeeded,
ensureElementAddressable,
Expand All @@ -13,6 +15,8 @@ interface GsapAnimationOpsParams {
commitMutation: CommitMutation;
commitMutationSafely: SafeGsapCommitMutation;
showToast: (message: string, tone?: "error" | "info") => void;
/** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
sdkSession?: Composition | null;
}

export function useGsapAnimationOps({
Expand All @@ -21,6 +25,7 @@ export function useGsapAnimationOps({
commitMutation,
commitMutationSafely,
showToast,
sdkSession,
}: GsapAnimationOpsParams) {
const updateGsapMeta = useCallback(
(
Expand Down Expand Up @@ -109,8 +114,25 @@ export function useGsapAnimationOps({
},
{ label: `Add GSAP ${method} animation` },
);

// Shadow: dispatch the equivalent addGsapTween to the SDK (server stays
// authoritative). "set" has no SDK method, so it is not shadowed.
// ponytail: only add is shadowed — delete/update key on the server's
// animationId, which doesn't resolve in the SDK's independent id-space.
if (sdkSession && selection.hfId && method !== "set") {
const tween: GsapTweenSpec = {
method,
position,
duration,
ease: "power2.out",
...(method === "fromTo"
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
: { properties: toDefaults[method] ?? { opacity: 1 } }),
};
runShadowGsapTween(sdkSession, { kind: "add", target: selection.hfId, tween });
}
},
[activeCompPath, commitMutation, projectIdRef, showToast],
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
);

return {
Expand Down
4 changes: 2 additions & 2 deletions packages/studio/src/hooks/useGsapScriptCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async function mutateGsapScript(

// oxfmt-ignore
// fallow-ignore-next-line complexity
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast }: GsapScriptCommitsParams) {
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
const commitMutation = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
const pid = projectIdRef.current;
if (!pid) return;
Expand Down Expand Up @@ -81,7 +81,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast });
const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast, sdkSession });
const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure });
const arcPathOps = useGsapArcPathOps(commitMutationSafely);
return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps };
Expand Down
19 changes: 17 additions & 2 deletions packages/studio/src/hooks/useTimelineEditing.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useCallback, useRef } from "react";
import type { Composition } from "@hyperframes/sdk";
import { runShadowTiming } from "../utils/sdkShadow";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { useRazorSplit } from "./useRazorSplit";
Expand Down Expand Up @@ -53,6 +55,8 @@ interface UseTimelineEditingOptions {
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
isRecordingRef?: React.RefObject<boolean>;
/** Stage 7 Step 3b: SDK session for shadow timing dispatch (server stays authoritative). */
sdkSession?: Composition | null;
}

// ── Hook ──
Expand All @@ -70,6 +74,7 @@ export function useTimelineEditing({
pendingTimelineEditPathRef,
uploadProjectFiles,
isRecordingRef,
sdkSession,
}: UseTimelineEditingOptions) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
Expand Down Expand Up @@ -138,6 +143,11 @@ export function useTimelineEditing({
value: String(updates.track),
});
}).then(() => {
if (sdkSession)
runShadowTiming(sdkSession, element.hfId, {
start: updates.start,
trackIndex: updates.track,
});
const pid = projectIdRef.current;
if (delta !== 0 && element.domId && pid) {
return shiftGsapPositions(pid, filePath, element.domId, delta)
Expand All @@ -146,7 +156,7 @@ export function useTimelineEditing({
}
});
},
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession],
);

const handleTimelineElementResize = useCallback(
Expand Down Expand Up @@ -190,6 +200,11 @@ export function useTimelineEditing({
}
return patched;
}).then(() => {
if (sdkSession)
runShadowTiming(sdkSession, element.hfId, {
start: updates.start,
duration: updates.duration,
});
const pid = projectIdRef.current;
if (timingChanged && element.domId && pid) {
return scaleGsapPositions(
Expand All @@ -207,7 +222,7 @@ export function useTimelineEditing({
return reloadPreview();
});
},
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession],
);

const handleTimelineElementDelete = useCallback(
Expand Down
104 changes: 102 additions & 2 deletions packages/studio/src/utils/sdkShadow.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import { describe, expect, it } from "vitest";
import { patchOpsToSdkEditOps, SdkShadowMismatch } from "./sdkShadow";
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
patchOpsToSdkEditOps,
runShadowDelete,
runShadowTiming,
runShadowGsapTween,
SdkShadowMismatch,
} from "./sdkShadow";
import type { PatchOperation } from "./sourcePatcher";
import { openComposition } from "@hyperframes/sdk";

// Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners.
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
vi.mock("./studioTelemetry", () => ({
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
trackedEvents.push({ event, props }),
}));
beforeEach(() => {
trackedEvents.length = 0;
});
const lastShadow = () =>
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;

const BASE_HTML = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
Expand Down Expand Up @@ -144,3 +162,85 @@ describe("sdkShadowDispatch (integration)", () => {
});
});
});

const TIMING_HTML = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-clip" data-start="0" data-duration="1" data-track="0">clip</div>
</body></html>`;

const GSAP_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box" style="opacity:0"></div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;</script>
</div>`;

const NO_TIMELINE_HTML = `<div data-hf-id="hf-stage" data-hf-root>
<div data-hf-id="hf-box"></div>
<script>gsap.defaults({ ease: "power1.out" });
window.__timelines = {};</script>
</div>`;

describe("runShadowDelete", () => {
it("removes the element from the SDK session and reports parity", async () => {
const session = await openComposition(BASE_HTML);
runShadowDelete(session, "hf-box");
expect(session.getElement("hf-box")).toBeNull();
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
});

it("reports no_hf_id when selection has no hf-id", async () => {
const session = await openComposition(BASE_HTML);
runShadowDelete(session, null);
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: false, reason: "no_hf_id" });
});

it("reports cannot_dispatch when the element is not addressable", async () => {
const session = await openComposition(BASE_HTML);
runShadowDelete(session, "hf-missing");
expect(lastShadow()).toMatchObject({
op: "delete",
dispatched: false,
reason: "cannot_dispatch",
});
});
});

describe("runShadowTiming", () => {
it("applies timing and reports parity against the snapshot", async () => {
const session = await openComposition(TIMING_HTML);
runShadowTiming(session, "hf-clip", { start: 2, duration: 3, trackIndex: 1 });
const el = session.getElement("hf-clip");
expect(el?.start).toBe(2);
expect(el?.duration).toBe(3);
expect(el?.trackIndex).toBe(1);
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
});
});

describe("runShadowGsapTween", () => {
it("dispatches add against a real timeline and reports success", async () => {
const session = await openComposition(GSAP_HTML);
runShadowGsapTween(session, {
kind: "add",
target: "hf-box",
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
});
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
});

it("reports cannot_dispatch (E_NO_GSAP_TIMELINE) when the script has no timeline", async () => {
const session = await openComposition(NO_TIMELINE_HTML);
runShadowGsapTween(session, {
kind: "add",
target: "hf-box",
tween: { method: "to", properties: { x: 100 } },
});
expect(lastShadow()).toMatchObject({
op: "gsap",
dispatched: false,
reason: "cannot_dispatch",
code: "E_NO_GSAP_TIMELINE",
});
});
});
Loading
Loading