diff --git a/bun.lock b/bun.lock index a4ced5562d..6a5067de13 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.104", + "version": "0.6.95", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.104", + "version": "0.6.95", "bin": { "hyperframes": "./dist/cli.js", }, @@ -101,13 +101,12 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.104", + "version": "0.6.95", "dependencies": { "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", "acorn": "^8.17.0", "acorn-walk": "^8.3.5", - "bpm-detective": "^2.0.5", "magic-string": "^0.30.21", "postcss": "^8.5.8", "postcss-selector-parser": "^7.1.2", @@ -135,7 +134,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.104", + "version": "0.6.95", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -153,7 +152,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.6.104", + "version": "0.6.95", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -173,7 +172,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.104", + "version": "0.6.95", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -185,7 +184,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.104", + "version": "0.6.95", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -226,7 +225,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.6.104", + "version": "0.6.91", "dependencies": { "@hyperframes/core": "workspace:*", "linkedom": "^0.18.12", @@ -239,7 +238,6 @@ }, "packages/sdk-playground": { "name": "@hyperframes/sdk-playground", - "version": "0.6.103", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/sdk": "workspace:*", @@ -251,7 +249,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.104", + "version": "0.6.95", "dependencies": { "html2canvas": "^1.4.1", }, @@ -263,7 +261,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.104", + "version": "0.6.95", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -277,9 +275,7 @@ "@codemirror/view": "6.40.0", "@hyperframes/core": "workspace:*", "@hyperframes/player": "workspace:*", - "@hyperframes/sdk": "workspace:*", "@phosphor-icons/react": "^2.1.10", - "bpm-detective": "^2.0.5", "mediabunny": "^1.45.3", }, "devDependencies": { @@ -1180,8 +1176,6 @@ "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], - "bpm-detective": ["bpm-detective@2.0.5", "", {}, "sha512-FaHFT5WDCR5zwtjVTqxk01o2MDaf22ORHityX1lxMamBcVNX6NWXB6hw7nx4fmlEseRMv0xfuSybINZylljLfA=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], diff --git a/packages/core/src/parsers/gsapWriter.acorn.test.ts b/packages/core/src/parsers/gsapWriter.acorn.test.ts index d015b92a8b..839b1079b1 100644 --- a/packages/core/src/parsers/gsapWriter.acorn.test.ts +++ b/packages/core/src/parsers/gsapWriter.acorn.test.ts @@ -269,6 +269,14 @@ describe("T6c — keyframe write ops", () => { expect((result.match(/"50%"/g) ?? []).length).toBe(1); }); + it("addKeyframeToScript merges a new property into an existing keyframe, preserving siblings", () => { + // 50% already holds { opacity: 0.7 }; adding x must NOT drop opacity. + const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { x: 100 }); + expect(result).toContain("opacity: 0.7"); + expect(result).toContain("x: 100"); + expect((result.match(/"50%"/g) ?? []).length).toBe(1); + }); + it("removeKeyframeFromScript removes the target percentage", () => { // Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c) const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 3186e7a2a1..ce8d6aba9c 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -345,7 +345,17 @@ export function addKeyframeToScript( const existing = findKfPropByPct(kfNode, percentage); if (existing) { - ms.overwrite(existing.prop.value.start, existing.prop.value.end, valueCode); + // Merge into the existing keyframe at this percentage, preserving sibling + // properties — overwrite only the given keys. (A whole-value overwrite here + // would silently drop other properties already keyframed at this percent.) + if (existing.prop.value?.type === "ObjectExpression") { + for (const [k, v] of Object.entries(properties)) { + upsertProp(ms, existing.prop.value, k, v); + } + if (ease !== undefined) upsertProp(ms, existing.prop.value, "ease", ease); + } else { + ms.overwrite(existing.prop.value.start, existing.prop.value.end, valueCode); + } } else { const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p)); let insertBeforeProp: any = null; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 66411d087e..659d101640 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -190,6 +190,7 @@ export function StudioApp() { uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, sdkSession, + forceReloadSdkSession, }); const { activeBlockParams, @@ -261,14 +262,10 @@ export function StudioApp() { ? () => handleToggleRecordingRef.current() : undefined, }); - const selectSidebarTabStable = useCallback( - (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), - [], - ); - const getSidebarTabStable = useCallback( - () => leftSidebarRef.current?.getTab() ?? "compositions", - [], - ); + const sidebarTabRef = useRef({ + select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t), + get: () => leftSidebarRef.current?.getTab() ?? "compositions", + }); const domEditSession = useDomEditSession({ projectId, activeCompPath, @@ -301,9 +298,10 @@ export function StudioApp() { reloadPreview, setRefreshKey, openSourceForSelection: fileManager.openSourceForSelection, - selectSidebarTab: selectSidebarTabStable, - getSidebarTab: getSidebarTabStable, + selectSidebarTab: sidebarTabRef.current.select, + getSidebarTab: sidebarTabRef.current.get, sdkSession, + forceReloadSdkSession, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; @@ -358,7 +356,6 @@ export function StudioApp() { resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); const dragOverlay = useDragOverlay(fileManager.handleImportFiles); - // Gesture recording const handleToggleRecordingRef = useRef<() => void>(() => {}); const domEditSessionRef = useRef(domEditSession); diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 29a94d818a..dc08c6d61a 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -88,15 +88,4 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag( 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 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"], - true, -); - export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index b4bd05c492..855ae638ba 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -59,6 +59,9 @@ 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). */ + /** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */ sdkSession?: Composition | null; + writeProjectFile?: (path: string, content: string) => Promise; + /** Resync the in-memory SDK session after a server-authoritative write. */ + forceReloadSdkSession?: () => void; } diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 55460128eb..583ee3507a 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -14,7 +14,9 @@ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; import { useDomGeometryCommits } from "./useDomGeometryCommits"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; -import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; + +// Re-export so existing consumers keep their import path +export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits"; // ── Helpers ── @@ -32,7 +34,11 @@ async function readErrorResponseBody( function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string { if (!body?.error) return "Couldn't save edit"; - return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`; + const fields = Array.isArray(body.fields) + ? body.fields.filter((field): field is string => typeof field === "string") + : []; + const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : ""; + return `Couldn't save edit: ${body.error}${suffix}`; } interface RecordEditInput { @@ -42,6 +48,8 @@ interface RecordEditInput { files: Record; } +export type { PersistDomEditOperations } from "./domEditCommitTypes"; + export interface UseDomEditCommitsParams { activeCompPath: string | null; previewIframeRef: React.MutableRefObject; @@ -68,10 +76,20 @@ export interface UseDomEditCommitsParams { target: HTMLElement, options?: { preferClipAncestor?: boolean }, ) => Promise; - /** 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; + /** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK + * path, whose session is already current) so a later SDK edit doesn't + * serialize the pre-write doc and revert the server's change. */ + forceReloadSdkSession?: () => void; + /** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */ + onTrySdkPersist?: ( + selection: DomEditSelection, + operations: PatchOperation[], + originalContent: string, + targetPath: string, + options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean }, + ) => Promise; + /** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */ + onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; } export function useDomEditCommits({ @@ -92,8 +110,9 @@ export function useDomEditCommits({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, - onDomEditPersisted, - onElementDeleted, + forceReloadSdkSession, + onTrySdkPersist, + onTrySdkDelete, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( (fontFamilyValue: string): ImportedFontAsset | null => { @@ -129,7 +148,6 @@ export function useDomEditCommits({ if (options?.shouldSave && !options.shouldSave()) return; const targetPath = selection.sourceFile || activeCompPath || "index.html"; - const readResponse = await fetch( `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, ); @@ -141,9 +159,23 @@ export function useDomEditCommits({ if (typeof originalContent !== "string") { throw new Error(`Missing file contents for ${targetPath}`); } - if (options?.shouldSave && !options.shouldSave()) return; - + // Skip the SDK path when prepareContent is set (e.g. @font-face injection + // for a custom font): sdkCutoverPersist serializes only the patched DOM + // and would drop the injected content. Let the server path run prepareContent. + if ( + onTrySdkPersist && + !options?.prepareContent && + (await onTrySdkPersist(selection, operations, originalContent, targetPath, { + label: options?.label, + coalesceKey: options?.coalesceKey, + skipRefresh: options?.skipRefresh, + })) + ) { + // SDK handled it — its in-memory doc is already current, so do NOT + // forceReload (that would echo-reload the session we just wrote). + return; + } const patchTarget = buildDomEditPatchTarget(selection); const patchBody = { target: patchTarget, operations }; const unsafeFields = findUnsafeDomPatchValues(patchBody); @@ -157,7 +189,6 @@ export function useDomEditCommits({ // handler suppresses the reload even if the event arrives before the // response (the server writes the file and emits SSE during the fetch). domEditSaveTimestampRef.current = Date.now(); - const patchResponse = await fetch( `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, { @@ -215,7 +246,7 @@ export function useDomEditCommits({ coalesceKey: options?.coalesceKey, files: { [targetPath]: { before: originalContent, after: finalContent } }, }); - onDomEditPersisted?.(selection, operations); + forceReloadSdkSession?.(); if (!options?.skipRefresh) { reloadPreview(); @@ -229,7 +260,8 @@ export function useDomEditCommits({ domEditSaveTimestampRef, reloadPreview, showToast, - onDomEditPersisted, + forceReloadSdkSession, + onTrySdkPersist, ], ); @@ -289,8 +321,9 @@ export function useDomEditCommits({ projectIdRef, reloadPreview, clearDomSelection, + onTrySdkDelete, + forceReloadSdkSession, commitPositionPatchToHtml, - onElementDeleted, }); return { diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index d7a4046889..b92eab5943 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,15 +1,15 @@ -import type { Composition } from "@hyperframes/sdk"; import type { TimelineElement } from "../player"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import type { RightPanelTab } from "../utils/studioHelpers"; import type { PatchTarget } from "../utils/sourcePatcher"; import type { SidebarTab } from "../components/sidebar/LeftSidebar"; +import type { Composition } from "@hyperframes/sdk"; +import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover"; import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; -import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapCacheVersion } from "./useGsapTweenCache"; import { useDomEditWiring } from "./useDomEditWiring"; @@ -60,8 +60,8 @@ export interface UseDomEditSessionParams { openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void; selectSidebarTab?: (tab: SidebarTab) => void; getSidebarTab?: () => SidebarTab; - /** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */ sdkSession?: Composition | null; + forceReloadSdkSession?: () => void; } // ── Hook ── @@ -101,6 +101,7 @@ export function useDomEditSession({ selectSidebarTab, getSidebarTab, sdkSession, + forceReloadSdkSession, }: UseDomEditSessionParams) { void _setRefreshKey; void _readProjectFile; @@ -195,6 +196,8 @@ export function useDomEditSession({ onFileContentChanged: updateEditingFileContent, showToast, sdkSession, + writeProjectFile, + forceReloadSdkSession, }); // ── DOM commit handlers ── @@ -233,10 +236,35 @@ export function useDomEditSession({ clearDomSelection, refreshDomEditSelectionFromPreview, buildDomSelectionFromTarget, - onDomEditPersisted: sdkSession - ? (sel, ops) => runShadowDispatch(sdkSession, sel, ops) + forceReloadSdkSession, + onTrySdkPersist: sdkSession + ? (selection, operations, originalContent, targetPath, options) => + sdkCutoverPersist( + selection, + operations, + originalContent, + targetPath, + sdkSession, + { + editHistory, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }, + options, + ) + : undefined, + onTrySdkDelete: sdkSession + ? (hfId, originalContent, targetPath) => + sdkDeletePersist(hfId, originalContent, targetPath, sdkSession, { + editHistory, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }) : undefined, - onElementDeleted: sdkSession ? (sel) => runShadowDelete(sdkSession, sel.hfId) : undefined, }); // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ── diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index 42d0fc2a85..ffe3822da4 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.ts +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -42,15 +42,11 @@ export function useDomGeometryCommits({ }: UseDomGeometryCommitsParams) { const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { - const gsapBlocked = isElementGsapTargeted(previewIframeRef.current, selection.element); - console.log( - "[drag:7] handleDomPathOffsetCommit (CSS path)", - JSON.stringify({ - sel: selection.id, - gsapBlocked, - }), - ); - if (gsapBlocked) { + // ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path + // elements fall through to commitPositionPatchToHtml → persistDomEditOperations → + // onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done). + // Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR. + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); showToast(error.message, "error"); return Promise.reject(error); diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index a30c5bb035..fed0be13b3 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -26,6 +26,10 @@ interface UseElementLifecycleOpsParams { projectIdRef: React.MutableRefObject; reloadPreview: () => void; clearDomSelection: () => void; + /** Route delete through SDK when session resolves the hf-id; returns true if handled. */ + onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise; + /** Resync the SDK session after a server-fallback delete. */ + forceReloadSdkSession?: () => void; commitPositionPatchToHtml: ( selection: DomEditSelection, patches: PatchOperation[], @@ -44,6 +48,8 @@ export function useElementLifecycleOps({ projectIdRef, reloadPreview, clearDomSelection, + onTrySdkDelete, + forceReloadSdkSession, commitPositionPatchToHtml, onElementDeleted, }: UseElementLifecycleOpsParams) { @@ -74,6 +80,16 @@ export function useElementLifecycleOps({ throw new Error("Selected element has no patchable target"); } + if (onTrySdkDelete && selection.hfId) { + const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath); + if (handled) { + clearDomSelection(); + usePlayerStore.getState().setSelectedElementId(null); + showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); + return; + } + } + domEditSaveTimestampRef.current = Date.now(); const removeResponse = await fetch( `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, @@ -93,6 +109,12 @@ export function useElementLifecycleOps({ const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string }; const patchedContent = typeof removeData.content === "string" ? removeData.content : originalContent; + // ponytail: the server remove-element route (removeElementFromHtml) strips + // only the element node — it does NOT cascade-remove GSAP tweens targeting + // it, unlike the SDK path (removeElement → cascadeRemoveAnimations). This + // fallback runs only when the element isn't in the SDK doc (e.g. runtime- + // generated / unaddressable), where targeting tweens are unlikely. Upgrade + // path: cascade in removeElementFromHtml by selector/hf-id to fully match. await saveProjectFilesWithHistory({ projectId: pid, label: "Delete element", @@ -105,6 +127,9 @@ export function useElementLifecycleOps({ clearDomSelection(); usePlayerStore.getState().setSelectedElementId(null); + // Server wrote the file; resync the stale in-memory SDK doc so a later + // SDK edit doesn't resurrect the deleted element. + forceReloadSdkSession?.(); reloadPreview(); onElementDeleted?.(selection); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); @@ -118,7 +143,8 @@ export function useElementLifecycleOps({ clearDomSelection, domEditSaveTimestampRef, editHistory.recordEdit, - onElementDeleted, + onTrySdkDelete, + forceReloadSdkSession, projectIdRef, reloadPreview, showToast, @@ -126,6 +152,9 @@ export function useElementLifecycleOps({ ], ); + // ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml → + // persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle. + // No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed. const handleDomZIndexReorderCommit = useCallback( ( entries: Array<{ diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index a184807ac0..66cec59fb7 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -2,21 +2,24 @@ import { useCallback } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { roundTo3 } from "../utils/rounding"; -import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow"; +import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover"; import { assignGsapTargetAutoIdIfNeeded, ensureElementAddressable, } from "./gsapScriptCommitHelpers"; import type { CommitMutation, SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; -interface GsapAnimationOpsParams { +interface SdkAnimationDeps { + sdkSession?: Composition | null; + sdkDeps?: CutoverDeps | null; +} + +interface GsapAnimationOpsParams extends SdkAnimationDeps { projectIdRef: React.MutableRefObject; activeCompPath: string | null; 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({ @@ -26,45 +29,59 @@ export function useGsapAnimationOps({ commitMutationSafely, showToast, sdkSession, + sdkDeps, }: GsapAnimationOpsParams) { const updateGsapMeta = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, updates: { duration?: number; ease?: string; position?: number }, ) => { - // Shadow op (server animationId shares the SDK id-space): existence via - // runShadowGsapTween (live session) + value fidelity via the chokepoint. - const shadowGsapOp: ShadowGsapOp = { - kind: "set", - animationId, - properties: { duration: updates.duration, ease: updates.ease, position: updates.position }, - }; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: updates }, + sdkSession, + sdkDeps, + { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "update-meta", animationId, updates }, - { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp }, + { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` }, ); - if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [commitMutationSafely, sdkSession], + [commitMutationSafely, activeCompPath, sdkSession, sdkDeps], ); const deleteGsapAnimation = useCallback( - (selection: DomEditSelection, animationId: string) => { - const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId }; + async (selection: DomEditSelection, animationId: string) => { + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "remove", animationId }, + sdkSession, + sdkDeps, + { label: "Delete GSAP animation" }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "delete", animationId, stripStudioEdits: true }, - { label: "Delete GSAP animation", shadowGsapOp }, + { label: "Delete GSAP animation" }, ); - if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [commitMutationSafely, sdkSession], + [commitMutationSafely, activeCompPath, sdkSession, sdkDeps], ); const deleteAllForSelector = useCallback( (selection: DomEditSelection, targetSelector: string) => { + // ponytail: no SDK op for delete-all-for-selector; stays server-authoritative void commitMutation( selection, { type: "delete-all-for-selector", targetSelector }, @@ -74,8 +91,7 @@ export function useGsapAnimationOps({ [commitMutation], ); - // Pre-existing complexity (auto-id assignment + per-method defaults); this PR - // adds only a guarded shadow-op construction at the tail. + // fallow-ignore-next-line complexity const addGsapAnimation = useCallback( // fallow-ignore-next-line complexity async ( @@ -110,25 +126,30 @@ export function useGsapAnimationOps({ fromTo: { x: 0, y: 0, opacity: 1 }, }; - // Shadow op (server stays authoritative). "set" has no SDK method, so it - // is not shadowed; otherwise: existence via runShadowGsapTween (live) + - // value fidelity via the chokepoint (shadowGsapOp in options). - const shadowGsapOp: ShadowGsapOp | undefined = - selection.hfId && method !== "set" - ? { - kind: "add", - target: selection.hfId, - tween: { - method, - position, - duration, - ease: "power2.out", - ...(method === "fromTo" - ? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] } - : { properties: toDefaults[method] ?? { opacity: 1 } }), - }, - } - : undefined; + // SDK path: addGsapTween only supports from/to/fromTo; "set" stays + // server-side. Skip the SDK path when an id was just assigned server-side + // (autoId): the SDK session hasn't reloaded that write yet, so persisting + // its serialization would clobber the new id — let the server add the + // tween atomically with the id it wrote. + if (!autoId && method !== "set" && selection.hfId && sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const spec = { + method: method as "to" | "from" | "fromTo", + position, + duration, + ease: "power2.out" as const, + properties: toDefaults[method] ?? { opacity: 1 }, + fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, + }; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "add", target: selection.hfId, spec }, + sdkSession, + sdkDeps, + { label: `Add GSAP ${method} animation` }, + ); + if (handled) return; + } await commitMutation( selection, @@ -142,12 +163,10 @@ export function useGsapAnimationOps({ properties: toDefaults[method] ?? { opacity: 1 }, fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, }, - { label: `Add GSAP ${method} animation`, shadowGsapOp }, + { label: `Add GSAP ${method} animation` }, ); - - if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession], + [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps], ); return { diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index 44b6635407..6f550fbfba 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -1,7 +1,9 @@ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { executeOptimistic } from "../utils/optimisticUpdate"; +import { sdkGsapKeyframePersist, type CutoverDeps } from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers"; @@ -30,7 +32,12 @@ function executeOptimisticKeyframeCacheUpdate(options: { }); } -interface GsapKeyframeOpsParams { +interface SdkKeyframeDeps { + sdkSession?: Composition | null; + sdkDeps?: CutoverDeps | null; +} + +interface GsapKeyframeOpsParams extends SdkKeyframeDeps { activeCompPath: string | null; commitMutation: CommitMutation; commitMutationSafely: SafeGsapCommitMutation; @@ -42,6 +49,8 @@ export function useGsapKeyframeOps({ commitMutation, commitMutationSafely, trackGsapSaveFailure, + sdkSession, + sdkDeps, }: GsapKeyframeOpsParams) { const addKeyframe = useCallback( ( @@ -61,42 +70,90 @@ export function useGsapKeyframeOps({ void executeOptimisticKeyframeCacheUpdate({ sourceFile, elementId: selection.id, - apply: (prev) => ({ - ...prev, - keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort( - (a, b) => a.percentage - b.percentage, - ), - }), - persist: () => - commitMutation(selection, mutation, { + // Merge into an existing keyframe at this percentage rather than + // appending a duplicate — matches addKeyframeToScript, which writes one + // keyframe per percentage (merging properties). + apply: (prev) => { + const idx = prev.keyframes.findIndex( + (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) < 0.001, + ); + if (idx >= 0) { + const keyframes = prev.keyframes.slice(); + keyframes[idx] = { + ...keyframes[idx], + properties: { ...keyframes[idx].properties, [property]: value }, + }; + return { ...prev, keyframes }; + } + return { + ...prev, + keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort( + (a, b) => a.percentage - b.percentage, + ), + }; + }, + persist: async () => { + if (sdkSession && sdkDeps) { + const handled = await sdkGsapKeyframePersist( + sourceFile, + animationId, + percentage, + { [property]: value }, + sdkSession, + sdkDeps, + { + label: `Add keyframe at ${percentage}%`, + coalesceKey: `gsap:${animationId}:kf:${percentage}`, + }, + ); + if (handled) return; + } + await commitMutation(selection, mutation, { label: `Add keyframe at ${percentage}%`, softReload: true, - }), + }); + }, }).catch((error) => { trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`); }); }, - [activeCompPath, commitMutation, trackGsapSaveFailure], + [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps], ); const addKeyframeBatch = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, percentage: number, properties: Record, ) => { + if (sdkSession && sdkDeps) { + const sourceFile = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapKeyframePersist( + sourceFile, + animationId, + percentage, + properties, + sdkSession, + sdkDeps, + { label: `Add keyframe at ${percentage}%` }, + ); + if (handled) return; + } return commitMutation( selection, { type: "add-keyframe", animationId, percentage, properties }, { label: `Add keyframe at ${percentage}%`, softReload: true }, ); }, - [commitMutation], + [commitMutation, activeCompPath, sdkSession, sdkDeps], ); const removeKeyframe = useCallback( (selection: DomEditSelection, animationId: string, percentage: number) => { + // ponytail: SDK removeGsapKeyframe uses keyframeIndex (not percentage); mismatch with + // Studio's percentage-based API. Resolving index requires parsing GSAP state at call + // time — deferred. removeKeyframe stays server-authoritative. const sourceFile = selection.sourceFile || activeCompPath || "index.html"; const mutation = { type: "remove-keyframe", animationId, percentage }; void executeOptimisticKeyframeCacheUpdate({ @@ -126,6 +183,7 @@ export function useGsapKeyframeOps({ animationId: string, resolvedFromValues?: Record, ) => { + // ponytail: no SDK equivalent; convertToKeyframes stays server-authoritative (T6f scope) return commitMutation( selection, { type: "convert-to-keyframes", animationId, resolvedFromValues }, @@ -137,6 +195,7 @@ export function useGsapKeyframeOps({ const removeAllKeyframes = useCallback( (selection: DomEditSelection, animationId: string) => { + // ponytail: no SDK equivalent for remove-all-keyframes; stays server-authoritative commitMutationSafely( selection, { type: "remove-all-keyframes", animationId }, diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.ts index 26f00e2a28..218397b654 100644 --- a/packages/studio/src/hooks/useGsapPropertyDebounce.ts +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.ts @@ -1,11 +1,22 @@ import { useCallback, useEffect, useRef } from "react"; +import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover"; import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers"; import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; const DEBOUNCE_MS = 150; -export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMutation) { +interface SdkPropertyDeps { + sdkSession?: Composition | null; + sdkDeps?: CutoverDeps | null; + activeCompPath?: string | null; +} + +export function useGsapPropertyDebounce( + commitMutationSafely: SafeGsapCommitMutation, + sdk?: SdkPropertyDeps, +) { const pendingPropertyEditRef = useRef<{ selection: DomEditSelection; animationId: string; @@ -14,11 +25,23 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta } | null>(null); const debounceTimerRef = useRef | null>(null); - const flushPendingPropertyEdit = useCallback(() => { + const flushPendingPropertyEdit = useCallback(async () => { const pending = pendingPropertyEditRef.current; if (!pending) return; pendingPropertyEditRef.current = null; const { selection, animationId, property, value } = pending; + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: { properties: { [property]: value } } }, + sdkSession, + sdkDeps, + { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "update-property", animationId, property, value }, @@ -28,7 +51,7 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta softReload: true, }, ); - }, [commitMutationSafely]); + }, [commitMutationSafely, sdk]); const updateGsapProperty = useCallback( ( @@ -39,7 +62,9 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta ) => { pendingPropertyEditRef.current = { selection, animationId, property, value }; if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = setTimeout(flushPendingPropertyEdit, DEBOUNCE_MS); + debounceTimerRef.current = setTimeout(() => { + void flushPendingPropertyEdit(); + }, DEBOUNCE_MS); }, [flushPendingPropertyEdit], ); @@ -47,12 +72,14 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta useEffect(() => { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - flushPendingPropertyEdit(); + void flushPendingPropertyEdit(); }; }, [flushPendingPropertyEdit]); + // fallow-ignore-next-line complexity const addGsapProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { + // fallow-ignore-next-line complexity + async (selection: DomEditSelection, animationId: string, property: string) => { let defaultValue = PROPERTY_DEFAULTS[property] ?? 0; const el = selection.element; if (property === "width" || property === "height") { @@ -62,17 +89,30 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta const cs = el.ownerDocument.defaultView?.getComputedStyle(el); defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1; } + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: { properties: { [property]: defaultValue } } }, + sdkSession, + sdkDeps, + { label: `Add GSAP ${property}` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "add-property", animationId, property, defaultValue }, { label: `Add GSAP ${property}` }, ); }, - [commitMutationSafely], + [commitMutationSafely, sdk], ); const removeGsapProperty = useCallback( (selection: DomEditSelection, animationId: string, property: string) => { + // ponytail: null ≠ removal in upsertProp; remove-property stays server-authoritative commitMutationSafely( selection, { type: "remove-property", animationId, property }, @@ -83,12 +123,27 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta ); const updateGsapFromProperty = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, property: string, value: number | string, ) => { + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { kind: "set", animationId, properties: { fromProperties: { [property]: value } } }, + sdkSession, + sdkDeps, + { + label: `Edit GSAP from-${property}`, + coalesceKey: `gsap:${animationId}:from:${property}`, + }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "update-from-property", animationId, property, value }, @@ -98,23 +153,40 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta }, ); }, - [commitMutationSafely], + [commitMutationSafely, sdk], ); const addGsapFromProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { + async (selection: DomEditSelection, animationId: string, property: string) => { const defaultValue = PROPERTY_DEFAULTS[property] ?? 0; + const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {}; + if (sdkSession && sdkDeps) { + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const handled = await sdkGsapTweenPersist( + targetPath, + { + kind: "set", + animationId, + properties: { fromProperties: { [property]: defaultValue } }, + }, + sdkSession, + sdkDeps, + { label: `Add GSAP from-${property}` }, + ); + if (handled) return; + } commitMutationSafely( selection, { type: "add-from-property", animationId, property, defaultValue }, { label: `Add GSAP from-${property}` }, ); }, - [commitMutationSafely], + [commitMutationSafely, sdk], ); const removeGsapFromProperty = useCallback( (selection: DomEditSelection, animationId: string, property: string) => { + // ponytail: null ≠ removal in upsertProp; remove-from-property stays server-authoritative commitMutationSafely( selection, { type: "remove-from-property", animationId, property }, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 6c0abe9fca..8f011f01e7 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -1,8 +1,8 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { applySoftReload } from "../utils/gsapSoftReload"; -import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity"; +import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"; +import type { CutoverDeps } from "../utils/sdkCutover"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { GsapMutationHttpError, @@ -44,9 +44,7 @@ async function mutateGsapScript( // oxfmt-ignore // fallow-ignore-next-line complexity -export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) { - // Pre-existing complexity (server mutate + history + reload branches); this PR - // adds only a guarded shadow-fidelity dispatch. +export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) { // fallow-ignore-next-line complexity const commitMutation = useCallback(async (selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { const pid = projectIdRef.current; @@ -68,25 +66,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra } if (result.changed === false) return; domEditSaveTimestampRef.current = Date.now(); - // Shadow value fidelity: diff the SDK's GSAP writer output against the - // server's, from the same pre-op file. Fire-and-forget; server authoritative. - // Only meta-level ops carry shadowGsapOp today (add / update-meta / delete via - // useGsapAnimationOps). Per-property and keyframe handlers (useGsapPropertyDebounce, - // useGsapKeyframeOps) intentionally don't synthesize one yet — deferred follow-up. - // scriptText is null when the composition has no GSAP script; nothing to diff. - const fidelityArgs = resolveGsapFidelityArgs( - sdkSession, - options.shadowGsapOp, - result.before, - result.scriptText, - ); - if (fidelityArgs) { - void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript); - } if (result.before != null && result.after != null) { await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); } if (result.after != null) onFileContentChanged?.(targetPath, result.after); + // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a + // later SDK-routed edit doesn't serialize the pre-write doc and revert this. + forceReloadSdkSession?.(); if (options.skipReload) return; if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation); options.beforeReload?.(); @@ -96,12 +82,66 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra reloadPreview(); } onCacheInvalidate(); - }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]); + }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]); const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast); - const propertyOps = useGsapPropertyDebounce(commitMutationSafely); - const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast, sdkSession }); - const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure }); + + // One stable SDK-deps object shared by all GSAP child hooks. Memoized so the + // hooks' callbacks keep a stable identity (an inline literal here re-fired the + // property-debounce flush on every render). refresh() soft-reloads (preserving + // the playhead) and invalidates the panel cache, matching the server path. + const sdkRefresh = useCallback( + (after: string) => { + const script = extractGsapScriptText(after); + if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview(); + onCacheInvalidate(); + }, + [previewIframeRef, reloadPreview, onCacheInvalidate], + ); + const sdkDeps = useMemo( + () => + writeProjectFile + ? { + editHistory: { recordEdit: editHistory.recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + refresh: sdkRefresh, + compositionPath: activeCompPath, + } + : null, + [ + editHistory.recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + sdkRefresh, + activeCompPath, + ], + ); + + const propertyOps = useGsapPropertyDebounce(commitMutationSafely, { + sdkSession, + sdkDeps, + activeCompPath, + }); + const animationOps = useGsapAnimationOps({ + projectIdRef, + activeCompPath, + commitMutation, + commitMutationSafely, + showToast, + sdkSession, + sdkDeps, + }); + const keyframeOps = useGsapKeyframeOps({ + activeCompPath, + commitMutation, + commitMutationSafely, + trackGsapSaveFailure, + sdkSession, + sdkDeps, + }); const arcPathOps = useGsapArcPathOps(commitMutationSafely); return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps }; } diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 0e22ba1b86..2c0e205011 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -1,4 +1,5 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; +import type { MutableRefObject } from "react"; import { openComposition } from "@hyperframes/sdk"; import { createHttpAdapter } from "@hyperframes/sdk/adapters/http"; import type { Composition } from "@hyperframes/sdk"; @@ -20,33 +21,47 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string * (projectId, activeCompPath) change, disposes the old one on cleanup, and * re-opens it when the active composition file changes on disk (code editor, * agent, or server-side patch) so the in-memory linkedom document never goes - * stale. - * - * Opened WITHOUT a persist queue: this session is shadow-telemetry + - * selection-sync only — it reads from the server but must NEVER write back. - * Shadow dispatch ops mutate the in-memory model and are discarded on the next - * reload-on-change (the studio's own authoritative write triggers it). Routing - * authoritative writes through this session (cutover, Step 3c+) must re-add - * persist TOGETHER WITH self-write suppression — without it, the SDK's - * serialize() output races and clobbers the studio's authoritative write. + * stale. The session has NO persist queue — Studio is the sole file writer; see + * the open effect below. */ +// Time-window heuristic: suppress file-change reloads for 2 s after our own +// SDK cutover write, to avoid an echo-reload on the write we just committed. +// Footgun: if 2 s is too short (slow FS / network) the reload fires anyway; +// if too long it masks a legitimate external edit. The long-term shape is a +// sequence number or content hash threaded through the persist event so the +// comparison is exact rather than time-based. +const SELF_WRITE_SUPPRESS_MS = 2000; + +export interface SdkSessionHandle { + session: Composition | null; + /** + * Force a session reload immediately, bypassing the self-write suppress + * window. Call after undo/redo writes the active composition file so the + * SDK in-memory document reflects the reverted content. + */ + forceReload: () => void; +} + export function useSdkSession( projectId: string | null, activeCompPath: string | null, -): Composition | null { + domEditSaveTimestampRef?: MutableRefObject, +): SdkSessionHandle { const [session, setSession] = useState(null); const [reloadToken, setReloadToken] = useState(0); // ── Re-open on external change to the active composition ── useEffect(() => { if (!activeCompPath) return; - // Pre-existing clone of the file-change reload handler (usePreviewPersistence); - // surfaced by this PR's adjacent edits, not introduced by it. - // fallow-ignore-next-line code-duplication const handler = (payload?: unknown) => { - if (shouldReloadSdkSession(payload, activeCompPath)) { - setReloadToken((t) => t + 1); - } + if (!shouldReloadSdkSession(payload, activeCompPath)) return; + // Suppress reload triggered by our own SDK cutover write. + if ( + domEditSaveTimestampRef && + Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS + ) + return; + setReloadToken((t) => t + 1); }; if (import.meta.hot) { import.meta.hot.on("hf:file-change", handler); @@ -56,6 +71,7 @@ export function useSdkSession( const es = new EventSource("/api/events"); es.addEventListener("file-change", handler); return () => es.close(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeCompPath]); // ── Open / re-open the session ── @@ -66,7 +82,7 @@ export function useSdkSession( } let cancelled = false; - let comp: Composition | null = null; + const compRef = { current: null as Composition | null }; const adapter = createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}`, @@ -75,15 +91,19 @@ export function useSdkSession( .read(activeCompPath) .then(async (content) => { if (cancelled || typeof content !== "string") return; - // No persist — shadow/selection only; see the hook docstring. The SDK - // must not write back to the server while it shadows the authoritative - // studio path. - comp = await openComposition(content); + // No persist queue: Studio's writeProjectFile (via sdkCutover's + // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist + // queue too would double-write the file (queue auto-writes on every + // 'change' AND Studio writes explicitly) and race on disk; it would + // also write the full active-composition serialization to the fixed + // persistPath even when an edit targeted a sub-composition file. + const comp = await openComposition(content); // Cleanup may have fired while openComposition was awaited; dispose immediately. if (cancelled) { comp.dispose(); return; } + compRef.current = comp; setSession(comp); }) .catch(() => { @@ -92,10 +112,12 @@ export function useSdkSession( return () => { cancelled = true; - const c = comp; - if (c) void c.flush().finally(() => c.dispose()); + // No queue to flush; dispose only. (Flushing here would serialize the + // pre-undo in-memory doc and race the revert write on undo/redo reload.) + compRef.current?.dispose(); }; }, [projectId, activeCompPath, reloadToken]); - return session; + const forceReload = useCallback(() => setReloadToken((t) => t + 1), []); + return { session, forceReload }; } diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 260bbb3105..56e0e48175 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -1,11 +1,4 @@ -// Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale + -// playback-start resolution); this PR adds guarded shadow-timing dispatches in -// the move/resize .then() chains, which nudges several callbacks over the CC -// threshold. The added branches are telemetry-only. -// fallow-ignore-file complexity import { useCallback, useRef } from "react"; -import type { Composition } from "@hyperframes/sdk"; -import { runShadowDelete, runShadowTiming } from "../utils/sdkShadow"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { useRazorSplit } from "./useRazorSplit"; @@ -33,10 +26,10 @@ import { readFileContent, applyPatchByTarget, formatTimelineAttributeNumber, - shiftGsapPositions, - scaleGsapPositions, } from "./timelineEditingHelpers"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; +import { sdkTimingPersist } from "../utils/sdkCutover"; +import type { Composition } from "@hyperframes/sdk"; // ── Types ── @@ -60,8 +53,10 @@ interface UseTimelineEditingOptions { pendingTimelineEditPathRef: React.MutableRefObject>; uploadProjectFiles: (files: Iterable, dir?: string) => Promise; isRecordingRef?: React.RefObject; - /** Stage 7 Step 3b: SDK session for shadow timing dispatch (server stays authoritative). */ + /** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */ sdkSession?: Composition | null; + /** Resync the SDK session after a server-authoritative timeline write. */ + forceReloadSdkSession?: () => void; } // ── Hook ── @@ -80,6 +75,7 @@ export function useTimelineEditing({ uploadProjectFiles, isRecordingRef, sdkSession, + forceReloadSdkSession, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -99,19 +95,24 @@ export function useTimelineEditing({ } const pid = projectIdRef.current; if (!pid) return Promise.resolve(); - const queued = editQueueRef.current.then(() => - persistTimelineEdit({ - projectId: pid, - element, - activeCompPath, - label, - buildPatches, - writeProjectFile, - recordEdit, - domEditSaveTimestampRef, - pendingTimelineEditPathRef, - }), - ); + const queued = editQueueRef.current + .then(() => + persistTimelineEdit({ + projectId: pid, + element, + activeCompPath, + label, + buildPatches, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, + }), + ) + .then(() => { + // Server wrote the file; resync the stale in-memory SDK doc. + forceReloadSdkSession?.(); + }); editQueueRef.current = queued.catch((error) => { console.error(`[Timeline] Failed to persist: ${label}`, error); }); @@ -125,18 +126,20 @@ export function useTimelineEditing({ pendingTimelineEditPathRef, showToast, isRecordingRef, + forceReloadSdkSession, ], ); + // fallow-ignore-next-line complexity const handleTimelineElementMove = useCallback( + // fallow-ignore-next-line complexity (element: TimelineElement, updates: Pick) => { patchIframeDomTiming(previewIframeRef.current, element, [ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-track-index", String(updates.track)], ]); - const delta = updates.start - element.start; - const filePath = element.sourceFile || activeCompPath || "index.html"; - return enqueueEdit(element, "Move timeline clip", (original, target) => { + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { let patched = applyPatchByTarget(original, target, { type: "attribute", property: "start", @@ -147,44 +150,52 @@ export function useTimelineEditing({ property: "track-index", 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) - .then(() => reloadPreview()) - .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err)); - } - }); + }; + if (sdkSession && element.hfId) { + return sdkTimingPersist( + element.hfId, + targetPath, + { start: updates.start, trackIndex: updates.track }, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }, + { label: "Move timeline clip", coalesceKey: `timeline-move:${element.hfId}` }, + ).then((handled) => { + if (!handled) return enqueueEdit(element, "Move timeline clip", buildMovePatches); + }); + } + return enqueueEdit(element, "Move timeline clip", buildMovePatches); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession], + [ + previewIframeRef, + enqueueEdit, + activeCompPath, + sdkSession, + recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + ], ); + // fallow-ignore-next-line complexity const handleTimelineElementResize = useCallback( + // fallow-ignore-next-line complexity ( element: TimelineElement, updates: Pick, ) => { - const liveAttrs: Array<[string, string]> = [ + patchIframeDomTiming(previewIframeRef.current, element, [ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-duration", formatTimelineAttributeNumber(updates.duration)], - ]; - if (updates.playbackStart != null) { - const liveAttr = - element.playbackStartAttr === "playback-start" - ? "data-playback-start" - : "data-media-start"; - liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]); - } - patchIframeDomTiming(previewIframeRef.current, element, liveAttrs); - const filePath = element.sourceFile || activeCompPath || "index.html"; - const timingChanged = - updates.start !== element.start || updates.duration !== element.duration; - return enqueueEdit(element, "Resize timeline clip", (original, target) => { + ]); + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { const pbs = resolveResizePlaybackStart(original, target, element, updates); let patched = applyPatchByTarget(original, target, { type: "attribute", @@ -204,34 +215,46 @@ 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( - pid, - filePath, - element.domId, - element.start, - element.duration, - updates.start, - updates.duration, - ) - .then(() => reloadPreview()) - .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err)); - } - return reloadPreview(); - }); + }; + // SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field). + // Condition: no explicit pbs override AND (no start change OR element has no pbs attribute). + const hasPbsAdjustment = + updates.playbackStart != null || + (updates.start !== element.start && element.playbackStart != null); + if (sdkSession && element.hfId && !hasPbsAdjustment) { + return sdkTimingPersist( + element.hfId, + targetPath, + { start: updates.start, duration: updates.duration }, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + compositionPath: activeCompPath, + }, + { label: "Resize timeline clip", coalesceKey: `timeline-resize:${element.hfId}` }, + ).then((handled) => { + if (!handled) return enqueueEdit(element, "Resize timeline clip", buildResizePatches); + }); + } + return enqueueEdit(element, "Resize timeline clip", buildResizePatches); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession], + [ + previewIframeRef, + enqueueEdit, + activeCompPath, + sdkSession, + recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + ], ); + // fallow-ignore-next-line complexity const handleTimelineElementDelete = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async (element: TimelineElement) => { if (isRecordingRef?.current) { @@ -287,8 +310,8 @@ export function useTimelineEditing({ timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)), ); usePlayerStore.getState().setSelectedElementId(null); + forceReloadSdkSession?.(); reloadPreview(); - if (sdkSession) runShadowDelete(sdkSession, element.hfId); showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete timeline clip"; @@ -304,12 +327,12 @@ export function useTimelineEditing({ domEditSaveTimestampRef, reloadPreview, isRecordingRef, - sdkSession, + forceReloadSdkSession, ], ); + // fallow-ignore-next-line complexity const handleTimelineAssetDrop = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async ( assetPath: string, @@ -373,6 +396,7 @@ export function useTimelineEditing({ recordEdit, }); + forceReloadSdkSession?.(); reloadPreview(); } catch (error) { const message = @@ -389,11 +413,12 @@ export function useTimelineEditing({ domEditSaveTimestampRef, reloadPreview, isRecordingRef, + forceReloadSdkSession, ], ); + // fallow-ignore-next-line complexity const handleTimelineFileDrop = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async (files: File[], placement?: Pick) => { if (isRecordingRef?.current) { diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 584658a765..e60e001d86 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -31,6 +31,19 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] { return results; } +/** + * Extract the GSAP timeline script text from a serialized HTML document, for + * feeding into applySoftReload. Returns null when zero or multiple GSAP scripts + * are present (ambiguous — caller should fall back to a full reload), matching + * applySoftReload's own single-script requirement. + */ +export function extractGsapScriptText(html: string): string | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + const scripts = findGsapScriptElements(doc); + if (scripts.length !== 1) return null; + return scripts[0].textContent || null; +} + /** Check that the new script repopulated __timelines with at least one entry. */ function verifyTimelinesPopulated(win: IframeWindow): boolean { const tlKeys = win.__timelines @@ -73,6 +86,7 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st // full iframe reload that destroys the very WebGL context we're preserving. let deferredToAsync = false; + // fallow-ignore-next-line complexity const doReload = () => { const timelines = win.__timelines; const allTargets: Element[] = []; diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts new file mode 100644 index 0000000000..737493c3f6 --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -0,0 +1,664 @@ +import { describe, expect, it, vi } from "vitest"; +import { + shouldUseSdkCutover, + sdkCutoverPersist, + sdkDeletePersist, + sdkTimingPersist, + sdkGsapTweenPersist, + sdkGsapKeyframePersist, +} from "./sdkCutover"; +import { openComposition } from "@hyperframes/sdk"; +import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; +import type { PatchOperation } from "./sourcePatcher"; +import type { MutableRefObject } from "react"; + +vi.mock("./studioTelemetry", () => ({ + trackStudioEvent: vi.fn(), +})); + +const styleOp = (property: string, value: string): PatchOperation => ({ + type: "inline-style", + property, + value, +}); + +const textOp = (value: string): PatchOperation => ({ + type: "text-content", + property: "text", + value, +}); + +const attrOp = (property: string, value: string): PatchOperation => ({ + type: "attribute", + property, + value, +}); + +const htmlAttrOp = (property: string, value: string): PatchOperation => ({ + type: "html-attribute", + property, + value, +}); + +describe("shouldUseSdkCutover", () => { + it("returns false when no session", () => { + expect(shouldUseSdkCutover(false, "hf-abc", [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when no hfId", () => { + expect(shouldUseSdkCutover(true, null, [styleOp("color", "red")])).toBe(false); + expect(shouldUseSdkCutover(true, undefined, [styleOp("color", "red")])).toBe(false); + }); + + it("returns false when ops empty", () => { + expect(shouldUseSdkCutover(true, "hf-abc", [])).toBe(false); + }); + + it("returns true for inline-style ops", () => { + expect(shouldUseSdkCutover(true, "hf-abc", [styleOp("color", "red")])).toBe(true); + }); + + it("returns true for text-content ops", () => { + expect(shouldUseSdkCutover(true, "hf-abc", [textOp("hello")])).toBe(true); + }); + + it("returns true for attribute ops", () => { + expect(shouldUseSdkCutover(true, "hf-abc", [attrOp("data-x", "10")])).toBe(true); + }); + + it("returns true for html-attribute ops", () => { + expect(shouldUseSdkCutover(true, "hf-abc", [htmlAttrOp("class", "foo")])).toBe(true); + }); + + it("returns true when ops mix all supported types", () => { + expect( + shouldUseSdkCutover(true, "hf-abc", [ + styleOp("color", "red"), + textOp("hello"), + attrOp("x", "1"), + htmlAttrOp("class", "foo"), + ]), + ).toBe(true); + }); +}); + +describe("sdkCutoverPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + + const makeDeps = (overrides: Partial[5]> = {}) => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + ...overrides, + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null), + dispatch: vi.fn(), + // Distinct before/after so the no-op guard (after === before → fall back) + // treats this as a real change; "after" matches the write assertions. + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue(""), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[4]; + + it("returns false when session is null", async () => { + const deps = makeDeps(); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/path.html", + null, + deps, + ); + expect(result).toBe(false); + }); + + it("returns false when element not found in session", async () => { + const deps = makeDeps(); + const session = makeSession(false); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/path.html", + session, + deps, + ); + expect(result).toBe(false); + }); + + it("dispatches setStyle for inline-style ops", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red"), styleOp("opacity", "0.5")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setStyle", + target: "hf-abc", + styles: { color: "red", opacity: "0.5" }, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", ""); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("dispatches setText for text-content op", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [textOp("Hello world")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setText", + target: "hf-abc", + value: "Hello world", + }); + }); + + it("dispatches setAttribute for attribute op with data- prefix", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [attrOp("x", "42")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setAttribute", + target: "hf-abc", + name: "data-x", + value: "42", + }); + }); + + it("dispatches setAttribute for html-attribute op", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [htmlAttrOp("class", "foo bar")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "setAttribute", + target: "hf-abc", + name: "class", + value: "foo bar", + }); + }); + + it("passes caller label to recordEdit", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, { + label: "Resize layer box", + }); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ label: "Resize layer box" }), + ); + }); + + it("passes caller coalesceKey to recordEdit", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, { + coalesceKey: "my-key", + }); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ coalesceKey: "my-key" }), + ); + }); + + it("returns false and does not throw on dispatch error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.dispatch as ReturnType).mockImplementation(() => { + throw new Error("dispatch failed"); + }); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(false); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); + + it("wraps all dispatches in session.batch() for atomic rollback", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const sel = { hfId: "hf-abc" } as never; + await sdkCutoverPersist( + sel, + [styleOp("color", "red"), styleOp("opacity", "0.5")], + "before", + "/comp.html", + session, + deps, + ); + expect( + (session as unknown as { batch: ReturnType }).batch, + ).toHaveBeenCalledOnce(); + }); + + it("returns false when second dispatch throws (batch prevents partial mutation)", async () => { + // inline-style ops coalesce into one setStyle dispatch; use style+text to produce two dispatches. + const deps = makeDeps(); + const session = makeSession(true); + let callCount = 0; + (session!.dispatch as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 2) throw new Error("2nd op failed"); + }); + const sel = { hfId: "hf-abc" } as never; + const result = await sdkCutoverPersist( + sel, + [styleOp("color", "red"), textOp("hello")], + "before", + "/comp.html", + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); +}); + +describe("sdkDeletePersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null), + removeElement: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before-snap") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[3]; + + it("returns false when session is null", async () => { + expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false); + }); + + it("returns false when element not found in session", async () => { + const session = makeSession(false); + expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe( + false, + ); + }); + + it("calls removeElement and writes serialized content", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); + expect(result).toBe(true); + expect(session!.removeElement).toHaveBeenCalledWith("hf-abc"); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + }); + + it("records edit history with before/after diff", async () => { + const deps = makeDeps(); + const session = makeSession(true); + await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Delete element", + files: { "/comp.html": { before: "before-content", after: "after" } }, + }), + ); + }); + + it("calls reloadPreview on success", async () => { + const deps = makeDeps(); + const session = makeSession(true); + await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("returns false and does not write on removeElement error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.removeElement as ReturnType).mockImplementation(() => { + throw new Error("remove failed"); + }); + const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + expect(deps.reloadPreview).not.toHaveBeenCalled(); + }); +}); + +describe("sdkTimingPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-clip" } : null), + setTiming: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[3]; + + it("returns false when session is null", async () => { + expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).toBe( + false, + ); + }); + + it("returns false when element not found in session", async () => { + const session = makeSession(false); + expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).toBe( + false, + ); + }); + + it("calls setTiming with provided update and writes serialized content", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const result = await sdkTimingPersist( + "hf-clip", + "/comp.html", + { start: 2, duration: 5, trackIndex: 1 }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", { + start: 2, + duration: 5, + trackIndex: 1, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + }); + + it("captures before-state before setTiming dispatch", async () => { + const deps = makeDeps(); + const session = makeSession(true); + await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + files: { "/comp.html": { before: "before", after: "after" } }, + }), + ); + }); + + it("returns false and does not write on setTiming error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.setTiming as ReturnType).mockImplementation(() => { + throw new Error("timing error"); + }); + const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); +}); + +describe("sdkGsapTweenPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = (opts?: { addGsapTween?: string; hasEl?: boolean }) => + ({ + getElement: vi.fn().mockReturnValue(opts?.hasEl !== false ? { id: "hf-box" } : null), + addGsapTween: vi.fn().mockReturnValue(opts?.addGsapTween ?? "tw-1"), + setGsapTween: vi.fn(), + removeGsapTween: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[2]; + + it("returns false when session is null", async () => { + expect( + await sdkGsapTweenPersist( + "/comp.html", + { kind: "remove", animationId: "tw-1" }, + null, + makeDeps(), + ), + ).toBe(false); + }); + + it("calls addGsapTween and writes for kind=add", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapTweenPersist( + "/comp.html", + { + kind: "add", + target: "hf-box", + spec: { method: "to", duration: 1, properties: { opacity: 1 } }, + }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.addGsapTween).toHaveBeenCalledWith( + "hf-box", + expect.objectContaining({ method: "to" }), + ); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + }); + + it("returns false for kind=add when element not found", async () => { + const deps = makeDeps(); + const session = makeSession({ hasEl: false }); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "add", target: "hf-box", spec: { method: "to", properties: { x: 100 } } }, + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); + + it("calls setGsapTween and writes for kind=set", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" }); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("calls removeGsapTween for kind=remove", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "remove", animationId: "tw-1" }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1"); + }); + + it("returns false and does not write on SDK error", async () => { + const deps = makeDeps(); + const session = makeSession(); + (session!.removeGsapTween as ReturnType).mockImplementation(() => { + throw new Error("gsap error"); + }); + const result = await sdkGsapTweenPersist( + "/comp.html", + { kind: "remove", animationId: "tw-1" }, + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); +}); + +describe("sdkGsapKeyframePersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = () => + ({ + dispatch: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue("after"), + batch: vi.fn((fn: () => void) => fn()), + }) as unknown as Parameters[4]; + + it("returns false when session is null", async () => { + expect( + await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()), + ).toBe(false); + }); + + it("dispatches addGsapKeyframe and writes serialized content", async () => { + const deps = makeDeps(); + const session = makeSession(); + const result = await sdkGsapKeyframePersist( + "/comp.html", + "tw-1", + 50, + { opacity: 0.5 }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.dispatch).toHaveBeenCalledWith({ + type: "addGsapKeyframe", + animationId: "tw-1", + position: 50, + value: { opacity: 0.5 }, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + expect(deps.reloadPreview).toHaveBeenCalled(); + }); + + it("returns false and does not write on dispatch error", async () => { + const deps = makeDeps(); + const session = makeSession(); + (session!.dispatch as ReturnType).mockImplementation(() => { + throw new Error("dispatch failed"); + }); + const result = await sdkGsapKeyframePersist( + "/comp.html", + "tw-1", + 25, + { x: 100 }, + session, + deps, + ); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); +}); + +describe("sdkCutoverPersist — GSAP script preservation (integration)", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + it("preserves GSAP +`; + const comp = await openComposition(html, { persist: createMemoryAdapter() }); + const deps = makeDeps(); + const sel = { hfId: "hf-layer" } as never; + const result = await sdkCutoverPersist( + sel, + [{ type: "inline-style", property: "color", value: "red" }], + html, + "/comp.html", + comp, + deps, + ); + expect(result).toBe(true); + const written = (deps.writeProjectFile as ReturnType).mock + .calls[0]?.[1] as string; + expect(written).toContain("data-hf-gsap"); + expect(written).toContain('data-position-mode="relative"'); + expect(written).toContain("gsap.timeline()"); + }); +}); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts new file mode 100644 index 0000000000..fdaecc9e9e --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.ts @@ -0,0 +1,273 @@ +import type { MutableRefObject } from "react"; +import type { Composition, GsapTweenSpec } from "@hyperframes/sdk"; +import type { EditOp } from "@hyperframes/sdk"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import type { EditHistoryKind } from "./editHistory"; +import type { PatchOperation } from "./sourcePatcher"; +import { trackStudioEvent } from "./studioTelemetry"; + +const CUTOVER_OP_TYPES = new Set([ + "inline-style", + "text-content", + "attribute", + "html-attribute", +]); + +/** + * Map Studio PatchOperations for a given hf-id to SDK EditOps. + * + * Multiple inline-style ops are coalesced into a single setStyle (SDK batches + * style changes naturally). One SDK op is emitted per non-style op. + */ +function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] { + const result: EditOp[] = []; + const styles: Record = {}; + let hasStyles = false; + + for (const op of ops) { + if (op.type === "inline-style") { + styles[op.property] = op.value; + hasStyles = true; + } else if (op.type === "text-content") { + result.push({ type: "setText", target: hfId, value: op.value ?? "" }); + } else if (op.type === "attribute") { + result.push({ + type: "setAttribute", + target: hfId, + name: op.property.startsWith("data-") ? op.property : `data-${op.property}`, + value: op.value, + }); + } else if (op.type === "html-attribute") { + result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value }); + } + } + + if (hasStyles) { + result.unshift({ type: "setStyle", target: hfId, styles }); + } + + return result; +} + +export function shouldUseSdkCutover( + hasSession: boolean, + hfId: string | null | undefined, + ops: PatchOperation[], +): boolean { + return hasSession && !!hfId && ops.length > 0 && ops.every((o) => CUTOVER_OP_TYPES.has(o.type)); +} + +export interface CutoverDeps { + editHistory: { + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + }; + writeProjectFile: (path: string, content: string) => Promise; + reloadPreview: () => void; + domEditSaveTimestampRef: MutableRefObject; + /** + * Optional post-write refresh. When provided, it REPLACES the default + * reloadPreview() — the GSAP path passes one that soft-reloads (preserving + * the playhead) and invalidates the keyframe/gsap panel cache. Receives the + * serialized document just written. + */ + refresh?: (after: string) => void; + /** + * Path of the composition the SDK session was opened for. The session models + * ONLY this file (serialize() emits the whole active composition), so any edit + * whose targetPath differs (a sub-composition file) must take the server path + * — otherwise we'd write the full active-comp serialization into that file. + */ + compositionPath?: string | null; +} + +/** True when targetPath isn't the composition the SDK session models. */ +function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean { + return deps.compositionPath != null && targetPath !== deps.compositionPath; +} + +interface CutoverOptions { + label?: string; + coalesceKey?: string; + /** Skip the preview reload (mirrors the server path's skipRefresh). */ + skipRefresh?: boolean; +} + +// ponytail: internal; export only if a third caller appears. +// `after` is serialized once by the caller (which also did the no-op check +// against its pre-dispatch snapshot), so this never re-serializes. +async function persistSdkSerialize( + after: string, + targetPath: string, + originalContent: string, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + deps.domEditSaveTimestampRef.current = Date.now(); + await deps.writeProjectFile(targetPath, after); + await deps.editHistory.recordEdit({ + label: options?.label ?? "Edit layer", + kind: "manual", + ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}), + files: { [targetPath]: { before: originalContent, after } }, + }); + if (deps.refresh) deps.refresh(after); + else if (!options?.skipRefresh) deps.reloadPreview(); +} + +export async function sdkCutoverPersist( + selection: DomEditSelection, + ops: PatchOperation[], + originalContent: string, + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!shouldUseSdkCutover(!!sdkSession, selection.hfId, ops)) return false; + if (!sdkSession) return false; + const hfId = selection.hfId; + if (!hfId) return false; + if (!sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; + try { + const before = sdkSession.serialize(); + sdkSession.batch(() => { + for (const editOp of patchOpsToSdkEditOps(hfId, ops)) { + sdkSession.dispatch(editOp); + } + }); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, originalContent, deps, options); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { + hfId: selection.hfId ?? null, + error: String(err), + }); + return false; + } +} + +export async function sdkTimingPersist( + hfId: string, + targetPath: string, + timingUpdate: { start?: number; duration?: number; trackIndex?: number }, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!sdkSession || !sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; + try { + const before = sdkSession.serialize(); + sdkSession.batch(() => sdkSession.setTiming(hfId, timingUpdate)); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, before, deps, options); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) }); + return false; + } +} + +type SdkGsapTweenOp = + | { kind: "add"; target: string; spec: GsapTweenSpec } + | { kind: "set"; animationId: string; properties: Partial } + | { kind: "remove"; animationId: string }; + +export async function sdkGsapTweenPersist( + targetPath: string, + op: SdkGsapTweenOp, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!sdkSession) return false; + if (wrongCompositionFile(deps, targetPath)) return false; + try { + if (op.kind === "add" && !sdkSession.getElement(op.target)) return false; + const before = sdkSession.serialize(); + sdkSession.batch(() => { + if (op.kind === "add") { + sdkSession.addGsapTween(op.target, op.spec); + } else if (op.kind === "set") { + sdkSession.setGsapTween(op.animationId, op.properties); + } else { + sdkSession.removeGsapTween(op.animationId); + } + }); + const after = sdkSession.serialize(); + // No-op (stale animationId, unsupported shape e.g. from-prop on a plain + // tween): fall back to the server path so it surfaces the proper error + // instead of writing a phantom before==after undo step. Subsumes a + // per-op existence guard for the set/remove branches. + if (after === before) return false; + await persistSdkSerialize(after, targetPath, before, deps, options); + trackStudioEvent("sdk_cutover_success", { opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { error: String(err) }); + return false; + } +} + +export async function sdkGsapKeyframePersist( + targetPath: string, + animationId: string, + position: number, + value: Record, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!sdkSession) return false; + if (wrongCompositionFile(deps, targetPath)) return false; + try { + const before = sdkSession.serialize(); + sdkSession.batch(() => + sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value }), + ); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, before, deps, options); + trackStudioEvent("sdk_cutover_success", { opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { error: String(err) }); + return false; + } +} + +export async function sdkDeletePersist( + hfId: string, + originalContent: string, + targetPath: string, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, +): Promise { + if (!sdkSession || !sdkSession.getElement(hfId)) return false; + if (wrongCompositionFile(deps, targetPath)) return false; + try { + const before = sdkSession.serialize(); + sdkSession.batch(() => sdkSession.removeElement(hfId)); + const after = sdkSession.serialize(); + if (after === before) return false; + await persistSdkSerialize(after, targetPath, originalContent, deps, { + label: "Delete element", + }); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) }); + return false; + } +} diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts deleted file mode 100644 index a36405cd68..0000000000 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { - patchOpsToSdkEditOps, - runShadowDelete, - runShadowTiming, - runShadowGsapTween, - runShadowGsapFidelity, - gsapFidelityMismatches, - resolveGsapFidelityArgs, - SdkShadowMismatch, -} from "./sdkShadow"; -import type { ShadowGsapOp } 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 }> = []; -vi.mock("./studioTelemetry", () => ({ - trackStudioEvent: (event: string, props: Record) => - 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 */ ` - -
Hello
-`; - -describe("patchOpsToSdkEditOps", () => { - it("maps inline-style ops to a single setStyle EditOp", () => { - const ops: PatchOperation[] = [ - { type: "inline-style", property: "color", value: "#00f" }, - { type: "inline-style", property: "opacity", value: "0.5" }, - ]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "setStyle", - target: "hf-box", - styles: { color: "#00f", opacity: "0.5" }, - }); - }); - - it("maps text-content op to setText EditOp", () => { - const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" }); - }); - - it("maps attribute op to setAttribute with data- prefix", () => { - const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "setAttribute", - target: "hf-box", - name: "data-name", - value: "hero", - }); - }); - - it("maps html-attribute op to setAttribute without prefix", () => { - const ops: PatchOperation[] = [ - { type: "html-attribute", property: "contenteditable", value: "true" }, - ]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "setAttribute", - target: "hf-box", - name: "contenteditable", - value: "true", - }); - }); - - it("handles null value for attribute removal", () => { - const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }]; - const result = patchOpsToSdkEditOps("hf-box", ops); - expect(result[0]).toEqual({ - type: "setAttribute", - target: "hf-box", - name: "hidden", - value: null, - }); - }); - - it("returns empty array for unknown op types", () => { - const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[]; - expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0); - }); -}); - -describe("sdkShadowDispatch (integration)", () => { - it("applies ops and returns no mismatches when SDK matches expected values", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); - expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f"); - }); - - it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [ - { type: "inline-style", property: "background-color", value: "rgb(255, 79, 88)" }, - ]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); // was 1 before the kebab→camel read-back fix - expect(session.getElement("hf-box")?.inlineStyles.backgroundColor).toBe("rgb(255, 79, 88)"); - }); - - it("returns dispatched:false when hfId not found in session", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; - const result = sdkShadowDispatch(session, "hf-missing", ops); - - expect(result.dispatched).toBe(false); - expect(result.mismatches).toHaveLength(1); - expect(result.mismatches[0]).toMatchObject({ - kind: "element_not_found", - hfId: "hf-missing", - }); - }); - - it("applies text op and reads back via session.getElement", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }]; - sdkShadowDispatch(session, "hf-box", ops); - - expect(session.getElement("hf-box")?.text).toBe("Updated"); - }); - - it("applies attribute op and reads back via session.getElement", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }]; - sdkShadowDispatch(session, "hf-box", ops); - - expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero"); - }); - - // fallow-ignore-next-line code-duplication - it("does NOT false-mismatch studio-internal data-hf-* marker attributes", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - - // path-offset drags emit these already-data-prefixed, SDK-excluded markers. - const ops: PatchOperation[] = [ - { type: "attribute", property: "data-hf-studio-path-offset", value: "true" }, - ]; - const result = sdkShadowDispatch(session, "hf-box", ops); - - expect(result.dispatched).toBe(true); - expect(result.mismatches).toHaveLength(0); // filtered, not double-prefixed + flagged - }); - - it("returns dispatch_error when dispatch throws — does not propagate", async () => { - const { sdkShadowDispatch } = await import("./sdkShadow"); - const session = await openComposition(BASE_HTML); - // Poison dispatch so it throws on any call - session.dispatch = () => { - throw new Error("sdk internal error"); - }; - - const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }]; - let result: ReturnType | undefined; - expect(() => { - result = sdkShadowDispatch(session, "hf-box", ops); - }).not.toThrow(); - - expect(result!.dispatched).toBe(false); - expect(result!.mismatches).toHaveLength(1); - expect(result!.mismatches[0]).toMatchObject({ - kind: "dispatch_error", - hfId: "hf-box", - error: expect.stringContaining("sdk internal error"), - }); - }); -}); - -const TIMING_HTML = /* html */ ` - -
clip
-`; - -const GSAP_HTML = `
-
- -
`; - -const NO_TIMELINE_HTML = `
-
- -
`; - -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("add reports success and the new tween lands on the target's animationIds", async () => { - const session = await openComposition(GSAP_HTML); - const before = session.getElement("hf-box")?.animationIds.length ?? 0; - runShadowGsapTween(session, { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, - }); - expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1); - expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 }); - }); - - it("remove drops the tween from animationIds and reports parity", async () => { - const session = await openComposition(GSAP_HTML); - const animationId = session.getElement("hf-box")?.animationIds[0]; - expect(animationId).toBeDefined(); - runShadowGsapTween(session, { kind: "remove", animationId: animationId! }); - expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId); - 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", - }); - }); -}); - -const SCRIPT_A = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2); -window.__timelines["t"] = tl;`; - -describe("gsapFidelityMismatches", () => { - it("returns no mismatches for identical scripts", () => { - expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]); - }); - - it("flags a per-field value drift (duration)", () => { - const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9"); - const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A); - expect(mismatches.some((m) => m.property === "duration")).toBe(true); - }); - - it("flags a tween present in one script but not the other", () => { - const empty = `var tl = gsap.timeline({ paused: true }); -window.__timelines["t"] = tl;`; - const mismatches = gsapFidelityMismatches(empty, SCRIPT_A); - expect(mismatches.some((m) => m.property === "tween")).toBe(true); - }); - - it("does NOT flag property key-order differences (canonical compare)", () => { - const ab = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - const ba = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - expect(gsapFidelityMismatches(ab, ba)).toEqual([]); - }); - - it("does NOT flag number-vs-string-equivalent property values", () => { - const numeric = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - const stringy = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0); -window.__timelines["t"] = tl;`; - expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]); - }); - - it("matches the same element across different selector forms when a resolver is given", () => { - // SDK writes [data-hf-id="hf-x"], server writes .x — same element, same tween. - const sdk = `var tl = gsap.timeline({ paused: true }); -tl.to("[data-hf-id=\\"hf-x\\"]", { x: 200, duration: 0.8 }, 0.5); -window.__timelines["t"] = tl;`; - const server = `var tl = gsap.timeline({ paused: true }); -tl.to(".x", { x: 200, duration: 0.8 }, 0.5); -window.__timelines["t"] = tl;`; - const resolve = (sel: string) => (/hf-x|\.x/.test(sel) ? "hf-x" : sel); - // Without a resolver: selector-form divergence → present/absent mismatch. - expect(gsapFidelityMismatches(sdk, server).length).toBeGreaterThan(0); - // With a resolver: matched by element → no mismatch. - expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]); - }); -}); - -describe("runShadowGsapFidelity", () => { - const BEFORE_HTML = `
-
- -
`; - - it("reports zero mismatches when the SDK output matches the server script", async () => { - // Produce the "server" script by applying the same op via the SDK, so a - // faithful SDK writer must reproduce it exactly. - const ref = await openComposition(BEFORE_HTML); - const op = { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, - } as const; - ref.addGsapTween(op.target, op.tween); - const serverScript = - ref.serialize().match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""; - - await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); - expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 }); - }); - - it("reports mismatches when the server script diverges", async () => { - const op = { - kind: "add", - target: "hf-box", - tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, - } as const; - const ref = await openComposition(BEFORE_HTML); - ref.addGsapTween(op.target, op.tween); - const serverScript = ( - ref.serialize().match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "" - ).replace("100", "999"); - - await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); - const ev = lastShadow(); - expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true }); - expect(ev?.mismatchCount as number).toBeGreaterThan(0); - }); -}); - -describe("resolveGsapFidelityArgs (chokepoint wiring)", () => { - const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" }; - const session = {} as object; - - it("returns narrowed args when session, op, before, and serverScript are all present", () => { - expect(resolveGsapFidelityArgs(session, op, "before", "tl.to(...)")).toEqual({ - before: "before", - op, - serverScript: "tl.to(...)", - }); - }); - - it("returns null when no session (shadow not wired)", () => { - expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull(); - }); - - it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => { - expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull(); - }); - - it("returns null when serverScript is null (composition has no GSAP script)", () => { - expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull(); - }); - - it("returns null when before is null", () => { - expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull(); - }); -}); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts deleted file mode 100644 index 171d45b240..0000000000 --- a/packages/studio/src/utils/sdkShadow.ts +++ /dev/null @@ -1,460 +0,0 @@ -/** - * SDK shadow dispatch utilities for Stage 7 Step 3b. - * - * Shadow mode keeps the server patch path authoritative while also dispatching - * the equivalent op to the SDK session, then compares the result to detect - * addressing gaps (blocker E: no-hf-id elements) and serialization drift - * (blocker B: linkedom whole-doc serialize). Results are reported as structured - * mismatches for telemetry — no user-visible change. - */ - -import type { Composition } from "@hyperframes/sdk"; -import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk"; -import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; -import { trackStudioEvent } from "./studioTelemetry"; -import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import type { PatchOperation } from "./sourcePatcher"; - -// ─── Op mapping ────────────────────────────────────────────────────────────── - -/** - * Map Studio PatchOperations for a given hf-id to SDK EditOps. - * - * Multiple inline-style ops are coalesced into a single setStyle (SDK batches - * style changes naturally). One SDK op is emitted per non-style op. - */ -// "attribute" PatchOperations carry the data- attribute NAME. Studio passes -// some already prefixed (e.g. "data-hf-studio-path-offset") and some bare -// (e.g. "name"); prefix only when needed, never double-prefix. -function attrName(property: string): string { - return property.startsWith("data-") ? property : `data-${property}`; -} - -// The SDK element model excludes data-hf-* attributes (document.ts skips them), -// so shadowing studio-internal markers (data-hf-studio-path-offset, etc.) can -// never match — drop those ops from the shadow instead of false-mismatching. -function isShadowableOp(op: PatchOperation): boolean { - if (op.type === "attribute") return !attrName(op.property).startsWith("data-hf-"); - if (op.type === "html-attribute") return !op.property.startsWith("data-hf-"); - return true; -} - -export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] { - const result: EditOp[] = []; - const styles: Record = {}; - let hasStyles = false; - - for (const op of ops) { - if (op.type === "inline-style") { - styles[op.property] = op.value; - hasStyles = true; - } else if (op.type === "text-content") { - result.push({ type: "setText", target: hfId, value: op.value ?? "" }); - } else if (op.type === "attribute") { - result.push({ - type: "setAttribute", - target: hfId, - name: attrName(op.property), - value: op.value, - }); - } else if (op.type === "html-attribute") { - result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value }); - } - // unknown op types produce no SDK op - } - - if (hasStyles) { - result.unshift({ type: "setStyle", target: hfId, styles }); - } - - return result; -} - -// ─── Shadow result types ────────────────────────────────────────────────────── - -export interface SdkShadowMismatch { - kind: "element_not_found" | "value_mismatch" | "dispatch_error"; - hfId: string; - property?: string; - expected?: string | null; - actual?: string | null | undefined; - error?: string; -} - -export interface SdkShadowResult { - /** False if the element was not found in the SDK session. */ - dispatched: boolean; - mismatches: SdkShadowMismatch[]; -} - -// ─── Shadow dispatch ────────────────────────────────────────────────────────── - -type ElementSnapshot = ReturnType; -type OpFields = { - property: string; - expected: string | null | undefined; - actual: string | null | undefined; -}; - -type FlatSnapshot = { - styles: Record; - attrs: Record; - text: string | null; -}; - -function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot { - return { - styles: snap?.inlineStyles ?? {}, - attrs: Object.fromEntries( - Object.entries(snap?.attributes ?? {}).map(([k, v]) => [k, v ?? null]), - ), - text: snap?.text ?? null, - }; -} - -type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields; - -// Snapshot inlineStyles are camelCase (CSSStyleDeclaration convention); PatchOperation -// style properties are kebab-case ("background-color"). Convert for read-back, else -// every hyphenated property false-mismatches against a null actual. -function kebabToCamel(prop: string): string { - return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); -} - -const OP_FIELD_RESOLVERS: Record = { - "inline-style": (op, flat) => ({ - property: op.property, - expected: op.value, - actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null, - }), - "text-content": (op, flat) => ({ property: "text", expected: op.value ?? "", actual: flat.text }), - attribute: (op, flat) => ({ - property: attrName(op.property), - expected: op.value ?? null, - actual: flat.attrs[attrName(op.property)] ?? null, - }), - "html-attribute": (op, flat) => ({ - property: op.property, - expected: op.value ?? null, - actual: flat.attrs[op.property] ?? null, - }), -}; - -function resolveOpFields(op: PatchOperation, flat: FlatSnapshot): OpFields | null { - return OP_FIELD_RESOLVERS[op.type]?.(op, flat) ?? null; -} - -function checkOpParity( - op: PatchOperation, - flat: FlatSnapshot, - hfId: string, -): SdkShadowMismatch | null { - const fields = resolveOpFields(op, flat); - if (!fields || fields.actual === fields.expected) return null; - return { kind: "value_mismatch", hfId, ...fields }; -} - -/** - * Dispatch PatchOperations to the SDK session and return a parity report. - * - * If the element is not found by hfId, returns dispatched:false with a - * element_not_found mismatch (signals blocker E — element has no hf-id or - * SDK can't address it). - * - * On success, verifies that the SDK element snapshot reflects the applied - * values. Value mismatches indicate serialization or normalization drift. - * - * **persist:error drift risk**: the HTTP adapter fires persist:error on - * network failure but the SDK session is already mutated at that point. If - * the server file was not updated (e.g. 503), subsequent shadow parity - * comparisons here will see a diverged SDK session and produce false - * positives. Before flipping STUDIO_SDK_DISPATCH_ENABLED, verify the shadow - * window is clear of persist:error events. - */ - -export function sdkShadowDispatch( - session: Composition, - hfId: string, - ops: PatchOperation[], -): SdkShadowResult { - if (!session.getElement(hfId)) { - return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] }; - } - // Drop studio-internal markers the SDK model can't represent (data-hf-*), so - // canvas-drag/path-offset edits don't false-mismatch on bookkeeping attrs. - const shadowable = ops.filter(isShadowableOp); - try { - const sdkOps = patchOpsToSdkEditOps(hfId, shadowable); - session.batch(() => { - for (const op of sdkOps) session.dispatch(op); - }); - } catch (err) { - return { - dispatched: false, - mismatches: [{ kind: "dispatch_error", hfId, error: String(err) }], - }; - } - const flat = flattenSnapshot(session.getElement(hfId)); - const mismatches = shadowable - .map((op) => checkOpParity(op, flat, hfId)) - .filter((m): m is SdkShadowMismatch => m !== null); - return { dispatched: true, mismatches }; -} - -// ─── Telemetry reporting ────────────────────────────────────────────────────── - -/** - * Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry. - * Despite the telemetry focus, this function does mutate the SDK session — it - * is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false. - */ -// Property-path mismatches carry user content (inline-style values, edited -// text) in expected/actual. Scrub before telemetry: fully redact text-content -// values, length-cap the rest. The in-memory parity result keeps raw values. -function redactValueForTelemetry( - property: string | undefined, - value: string | null | undefined, -): string | null | undefined { - if (value == null) return value; - if (property === "text") return `[redacted len=${value.length}]`; - return value.length > 64 ? `${value.slice(0, 64)}…` : value; -} - -function redactMismatchesForTelemetry(mismatches: SdkShadowMismatch[]): SdkShadowMismatch[] { - return mismatches.map((m) => ({ - ...m, - expected: redactValueForTelemetry(m.property, m.expected), - actual: redactValueForTelemetry(m.property, m.actual), - })); -} - -export function runShadowDispatch( - session: Composition, - selection: DomEditSelection, - ops: PatchOperation[], -): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - const hfId = selection.hfId; - if (!hfId) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "property", - dispatched: false, - reason: "no_hf_id", - mismatchCount: 0, - }); - return; - } - const result = sdkShadowDispatch(session, hfId, ops); - trackStudioEvent("sdk_shadow_dispatch", { - op: "property", - dispatched: result.dispatched, - mismatchCount: result.mismatches.length, - mismatches: JSON.stringify(redactMismatchesForTelemetry(result.mismatches)), - }); -} - -// ─── Shadow for non-PatchOperation ops (delete / timing / GSAP) ─────────────── -// -// These ops never flow through persistDomEditOperations, so the property-path -// shadow above never sees them. Each runner keeps the server authoritative and -// only observes the SDK: can() pre-checks addressing/validity (pure, no -// mutation — works even for GSAP, which has no element-snapshot value), then a -// dispatch into the live session with a snapshot-based parity check. -// -// Parity coverage by op: -// delete → getElement(id) === null (full) -// timing → snapshot.start/duration/trackIndex (full) -// gsap → tween id present/absent in animationIds (existence only — the -// tween's property values are script-level, not in the snapshot) - -/** - * can()-gated shadow dispatch. Emits sdk_shadow_dispatch tagged with `opLabel`. - * Mutates the SDK session (not read-only); server stays authoritative. - * No-op when STUDIO_SDK_SHADOW_ENABLED is false. - */ -function runShadowEditOp( - session: Composition, - op: EditOp, - opLabel: string, - dispatchAndCheck: () => SdkShadowMismatch[], -): void { - const verdict = session.can(op); - if (!verdict.ok) { - trackStudioEvent("sdk_shadow_dispatch", { - op: opLabel, - dispatched: false, - reason: "cannot_dispatch", - code: verdict.code, - mismatchCount: 0, - }); - return; - } - let mismatches: SdkShadowMismatch[]; - try { - mismatches = dispatchAndCheck(); - } catch (err) { - trackStudioEvent("sdk_shadow_dispatch", { - op: opLabel, - dispatched: false, - reason: "dispatch_error", - error: String(err), - mismatchCount: 0, - }); - return; - } - trackStudioEvent("sdk_shadow_dispatch", { - op: opLabel, - dispatched: true, - mismatchCount: mismatches.length, - mismatches: JSON.stringify(mismatches), - }); -} - -/** Shadow an element delete. Parity: the element is gone from the SDK session. */ -export function runShadowDelete(session: Composition, hfId: string | null | undefined): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - if (!hfId) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "delete", - dispatched: false, - reason: "no_hf_id", - mismatchCount: 0, - }); - return; - } - const op: EditOp = { type: "removeElement", target: hfId }; - runShadowEditOp(session, op, "delete", () => { - session.batch(() => session.dispatch(op)); - return session.getElement(hfId) - ? [ - { - kind: "value_mismatch", - hfId, - property: "exists", - expected: "removed", - actual: "present", - }, - ] - : []; - }); -} - -export interface ShadowTiming { - start?: number; - duration?: number; - trackIndex?: number; -} - -/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */ -export function runShadowTiming( - session: Composition, - hfId: string | null | undefined, - timing: ShadowTiming, -): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - if (!hfId) { - trackStudioEvent("sdk_shadow_dispatch", { - op: "timing", - dispatched: false, - reason: "no_hf_id", - mismatchCount: 0, - }); - return; - } - const op: EditOp = { type: "setTiming", target: hfId, ...timing }; - runShadowEditOp(session, op, "timing", () => { - session.batch(() => session.dispatch(op)); - const el = session.getElement(hfId); - const mismatches: SdkShadowMismatch[] = []; - const fields: Array<[keyof ShadowTiming, number | null | undefined]> = [ - ["start", el?.start], - ["duration", el?.duration], - ["trackIndex", el?.trackIndex], - ]; - for (const [key, actual] of fields) { - const expected = timing[key]; - if (expected !== undefined && actual !== expected) { - mismatches.push({ - kind: "value_mismatch", - hfId, - property: key, - expected: String(expected), - actual: actual == null ? null : String(actual), - }); - } - } - return mismatches; - }); -} - -export type ShadowGsapOp = - | { kind: "add"; target: string; tween: GsapTweenSpec } - | { kind: "set"; animationId: string; properties: Partial } - | { kind: "remove"; animationId: string }; - -/** - * Shadow a GSAP tween mutation (add / set / remove). The server's animationId - * shares the SDK's id-space (both derive `targetSelector-method-position` from - * the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is. - * - * Parity via the now-populated ElementSnapshot.animationIds: - * add → the returned tween id is present on the target element - * remove → the id is gone from every element - * set → existence only (the SDK exposes no per-tween property reader; value - * fidelity would need serialize()-script round-trip diffing). - */ -export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void { - if (!STUDIO_SDK_SHADOW_ENABLED) return; - const op: EditOp = - gsapOp.kind === "add" - ? { type: "addGsapTween", target: gsapOp.target, tween: gsapOp.tween } - : gsapOp.kind === "set" - ? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties } - : { type: "removeGsapTween", animationId: gsapOp.animationId }; - // fallow-ignore-next-line complexity - runShadowEditOp(session, op, "gsap", () => { - let newId: string | undefined; - session.batch(() => { - if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween); - else session.dispatch(op); - }); - if (gsapOp.kind === "add") { - const onTarget = session.getElement(gsapOp.target)?.animationIds ?? []; - if (!newId || !onTarget.includes(newId)) { - return [ - { - kind: "value_mismatch", - hfId: gsapOp.target, - property: "animationIds", - expected: newId ?? "non-empty", - actual: onTarget.join(",") || null, - }, - ]; - } - } else if (gsapOp.kind === "remove") { - const stillPresent = session - .getElements() - .some((el) => el.animationIds.includes(gsapOp.animationId)); - if (stillPresent) { - return [ - { - kind: "value_mismatch", - hfId: gsapOp.animationId, - property: "animationIds", - expected: "removed", - actual: "present", - }, - ]; - } - } - return []; - }); -} - -// GSAP value-fidelity diff lives in its own module to keep this file under the -// 600-line studio cap; re-exported here so the shadow surface stays in one place. -export { - gsapFidelityMismatches, - resolveGsapFidelityArgs, - runShadowGsapFidelity, -} from "./sdkShadowGsapFidelity"; diff --git a/sdk-status-report.txt b/sdk-status-report.txt new file mode 100644 index 0000000000..abd054c225 --- /dev/null +++ b/sdk-status-report.txt @@ -0,0 +1,191 @@ +@hyperframes/sdk — Status Report +June 13, 2026 + + +OVERVIEW + +The SDK is a headless, framework-neutral composition editing engine for HyperFrames. +It lets hosts (Studio, AI agents, Pacific/HeyGen) read and mutate compositions through +a typed method layer that emits RFC 6902 patches. Work is split into 9 stages. +Stages 1–3a are complete and merged. Stages 3b and the acorn parser stack are code +complete and pending review. An interactive playground (sdk-playground) now exercises +the full op surface end-to-end against a live preview and file-backed persistence. + + +WHAT IS MERGED (DONE) + +Stage 1 — Design Decisions + All three gate decisions locked: + - Brand representation: CSS variables (not data-brand-* attributes) + - Timeline model: per-element start/duration/hold + - Storage model: content-addressed SHA-256 keys + +Stage 2 — Foundation (Phase 1) + Package scaffolding, types, GSAP serializer, PersistAdapter contract suite (T13), + three host-archetype usage examples. Fully shipped. + +Stage 3 — Browser-safe Parser (Phase 2) + Replaced recast/Babel (Node-only, 500KB) with acorn + magic-string (15KB, browser-safe). + This was the technical gate for all editing operations. Five PRs in a Graphite stack: + - #1338 lint rule: warn on missing data-no-timeline + - #1368 acorn read path + differential tests + - #1369 acorn write path (magic-string offset-splice) + - #1370 parse-parity suite (recast vs acorn agreement) + - #1392 swap studio-api files.ts from recast to acorn + +Stage 3a — Session API (Phase 3a) + Core session: document model, dispatch/patch loop, history coalescing, + PersistAdapter queue, optional history module. Merged PR #1325. + +Gesture-to-Keyframes Stack + The prerequisite for Studio migration step 3 (edit ops). PRs #1301 and #1311 merged. + + +WHAT IS CODE COMPLETE, IN REVIEW (latest first) + +Stage 4 (partial) — can() + T4 tests + FsAdapter T13 [June 13, PR #1425/#1426] + can() now returns structured CanResult ({ok:true} | {ok:false, code, message, hint?}) + instead of a bare boolean. validateOp() in mutate.ts matches the same shape. + CAN_OK constant + canErr() helper keep callers concise. + T4 dispatch-boundary tests (session.dispatch.test.ts) cover: + - patch events fire after dispatch + - override-set pipeline via setVariable + - can() CanResult shape for valid/invalid ops + - batch() applies all ops and emits a single patch event + - addGsapTween round-trip via session + - custom origin forwarded through patch event + FsAdapter flush() was a silent no-op: in-flight writes could be abandoned before + they settled, causing a torn write on fast save-quit. Fixed with inflightWrites: + Set> — flush() now awaits all in-flight promises. Also fixed a + same-millisecond version-key collision with a versionCounter monotonic suffix. + FsAdapter is now exercised by the T13 PersistAdapter contract suite, closing the + gap between the stub and the contract. + +Stage 3b — GSAP Editing Engine (Phase 3b) + 9 new SDK operations shipped in PR #1379: + addGsapTween, setGsapTween, removeGsapTween + addGsapKeyframe, setGsapKeyframe, removeGsapKeyframe + addLabel, removeLabel + setClassStyle + + A code review (June 12) found and fixed 6 bugs before the stack merges: + 1. Program-scope variable bindings were silently lost (querySelector at top + level was never resolved). Fixed with a null-key fallback in the scope chain. + 2. fromTo argument guard was too loose (could read undefined args[2]/args[3]). + 3. removeAnimation had a fuzzing fallback that silently deleted the wrong + animation by converting -from- IDs to -to- IDs. Removed. + 4. stagger property was handled in addGsapTween but missing from setGsapTween. + 5. apply-patches script case had no handler for op=remove. + 6. valueToCode had no NaN guard and used a wrong regex for property key safety. + + setTiming GSAP-sync fix (June 13, found via playground) + setTiming was a silent no-op for animation timing on GSAP compositions: it + stamped data-start/data-end on the DOM node, but the runtime re-stamps those + attributes FROM GSAP positions on next init, so the edit was overwritten. + handleSetTiming now also rewrites the GSAP script (parseGsapScriptAcornForWrite + + updateAnimationInScript), flushing both models in one patch pair so they stay + in sync. DAW-style trim in the playground depends on this. + + Stack is awaiting re-stamp from reviewers (Miguel on #1379, Rames on #1368/#1370). + +Stage 5 — Adapters (partial, in playground) + fs PersistAdapter is now implemented (was a stub): node:fs/promises read/write, + timestamped version history under .hf-versions//, prune to maxVersions (20), + listVersions/loadFrom. Still needs to be run against the T13 contract suite. + A fetch-based PersistAdapter (browser → Vite dev-server endpoints) provides the + HTTP shape ahead of the real post-Pacific HTTP adapter. + A concrete PreviewAdapter (PlaygroundPreview) implements select/on('selection') + and the draft/commit/cancel stubs — first real impl beyond the contract. + S3 + production HTTP adapters and the headless null PreviewAdapter remain. + +Stage 8 — Packaging and DX (partial) + sdk-playground shipped: interactive browser harness over the full op surface + (setStyle/setText/setAttribute/removeElement/setVariableValue/find/selection + proxy/all 9 GSAP+label ops/setClassStyle), live preview iframe with click-select + and drag-to-reposition, DAW-style timeline trim via setTiming, file-backed + persistence with version history, undo/redo/can/getOverrides/flush, raw-HTML + editor modal. README documents built vs planned. + @hyperframes/editor drop-in, CDN bundle, npm create scaffold, and the docs + ladder remain. + + +WHAT IS NOT STARTED + +Stage 4 remainder + removeElement v1 — cascade rules, override-set removal, soft/hard op classification, + inverse patch carrying full serialized subtree. + createHistory — 300ms coalescing, origin-scoped. + +Stage 5 remainder — Adapters (Phase 4) + S3 and production HTTP PersistAdapters (post-Pacific). + PreviewAdapter: headless null adapter for agents and CI. + +Stage 6 — Advanced (Phase 5) + Sub-composition editing via scoped element IDs. + Soft vs hard op classification (reversible vs destructive). + Testing utilities for hosts building on the SDK. + +Stage 7 — Studio Migration + 5-step feature-flag migration. Each step can be rolled back independently. + Step 1: persistence + Step 2: selection + Step 3: edit ops (commit handlers become dispatch calls — absorbs the old R5 track) + Step 4: history + Step 5: App.tsx collapse + Canvas, panel, and timeline components land in a new @hyperframes/react package. + This stage is roughly as much work as everything shipped so far. + +Stage 8 remainder — Packaging and DX + @hyperframes/editor: 5-line drop-in component plus CDN bundle. + npm create scaffold (playground itself is shipped — see above). + Full documentation ladder (quickstart, guides, generated reference, architecture explanation). + +Stage 9 — Pacific / AI Studio Integration + HyperframesElement draft type, draft_to_edit classification. + heygen_video_workflow fan-out to hyperframes_producer_activity (transparent WebM at + graphic-clip level). + Embedded session wiring, word-aligned timing with shared resolver. + + +SEPARATE TRACKS + +Runtime bridge freeze (R6) + Rename window.__* globals to versioned __HYPERFRAMES.* namespace. + Gated on Phase 1 (complete). Not yet scheduled. + +Write-path migration (T6f) + The read path now uses acorn everywhere. The write path (convertToKeyframes, + removeAllKeyframes, setArcPath, etc.) still uses recast. Full migration is the + next post-merge task for the parser team. + +GSAP plugin support branch + feat/gsap-plugin-support: code complete, 95 tests, no PR ever opened. + Branch is 79 days old and may be superseded by the acorn parser work. + Decision needed: open a PR, archive, or rebase onto current stack. + + +ROUGH PROGRESS + +Stages complete or in review: 1, 2, 3 (all), 3a, 3b, 4 (partial: can/T4/FsAdapter) +Stages partially done: 5 (fs + fetch adapters, PreviewAdapter impl), + 8 (playground shipped) +Stages not started: 4 (remainder: removeElement, createHistory), 6, 7, 9 + +Current stack is approximately 40% of the full roadmap by stage count. +Stage 7 (Studio Migration) and Stage 9 (Pacific) are the largest remaining efforts. + + +OPEN QUESTIONS FROM PRD + + 1. commitPreview() location — recommendation: core session API over PreviewAdapter + 2. setHold surface — recommendation: dispatch op + 3. Render granularity: per-element WebM now, per-scene layers later? + 4. Animation markers: expose GSAP labels for host-level sequencing? + 5. North star: model the entire Pacific draft as one SDK document? + + +CONTACTS + +Acorn parser / Phase 3b: Vance +Studio migration: TBD +Pacific integration: TBD