From 92e2c8ce6a0b6702f779dc3c50eb37fd3b84492e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 14:00:39 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20stage=207=20step=201=20?= =?UTF-8?q?=E2=80=94=20wire=20SDK=20session=20into=20Studio=20(#1443)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(studio): stage 7 step 1 — wire SDK session into Studio Creates useSdkSession hook: fetches active composition HTML, opens an SDK Composition backed by createHttpAdapter, disposes on comp/project change. Session is idle (no dispatch routed yet) — Step 3 wires edit ops through it. Also removes createFsAdapter from SDK main entry (Node-only; subpath-only: @hyperframes/sdk/adapters/fs). Required for Studio typecheck to pass when importing @hyperframes/sdk — fs.ts uses node:fs/promises which Studio's tsconfig does not include. Co-Authored-By: Claude Sonnet 4.6 * feat(studio): stage 7 step 2 — mirror canvas selection into SDK session useSdkSelectionSync: effect that calls session.setSelection(hfIds) whenever domEditSelection or domEditGroupSelections changes. Maps each entry's hfId; skips entries without one. Pure additive — no existing hook modified. Co-Authored-By: Claude Sonnet 4.6 * fix(studio): use adapter.read() in useSdkSession bootstrap Build the HttpAdapter first, then call adapter.read(activeCompPath) instead of duplicating URL construction with a raw fetch. Eliminates the /files/encode duplication already in HttpAdapter.read(). Co-Authored-By: Claude Sonnet 4.6 * fix(studio): flush in-flight http writes before disposing SDK session Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel * fix(studio): dispose SDK session if cleanup fires during openComposition Reviewer found a race: if the effect cleanup runs while openComposition is awaited, comp is null so cleanup is a no-op, but the composition is then set and never disposed. Add an explicit check after the await so any composition opened after cancellation is disposed immediately. Also wire the missing useSdkSession call in App.tsx (sdkSession was referenced but never declared — pre-existing typecheck failure), move the stableRenderQueue memo into useRenderQueue so App.tsx stays under the 600-line architecture gate. Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/sdk/src/index.ts | 4 +- packages/studio/package.json | 1 + packages/studio/src/App.tsx | 24 ++++---- .../src/components/renders/useRenderQueue.ts | 20 ++++--- .../studio/src/hooks/useSdkSelectionSync.ts | 25 ++++++++ packages/studio/src/hooks/useSdkSession.ts | 58 +++++++++++++++++++ 6 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 packages/studio/src/hooks/useSdkSelectionSync.ts create mode 100644 packages/studio/src/hooks/useSdkSession.ts diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 35616c60b..127110347 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -34,10 +34,8 @@ export type { PersistQueueModule, PersistQueueOptions } from "./persist-queue.js export type { PersistAdapter, PreviewAdapter, PersistVersionEntry } from "./adapters/types.js"; -// Concrete adapter factories. +// Concrete adapter factories (browser-safe — Node-only fs adapter: @hyperframes/sdk/adapters/fs). export { createMemoryAdapter } from "./adapters/memory.js"; export { createHeadlessAdapter } from "./adapters/headless.js"; -export { createFsAdapter } from "./adapters/fs.js"; -export type { FsAdapterOptions } from "./adapters/fs.js"; export { createHttpAdapter } from "./adapters/http.js"; export type { HttpAdapterOptions } from "./adapters/http.js"; diff --git a/packages/studio/package.json b/packages/studio/package.json index fedc20710..99b23bc41 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -39,6 +39,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" diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 39c9cc597..7b1407136 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -13,6 +13,8 @@ import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; +import { useSdkSession } from "./hooks/useSdkSession"; +import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync"; import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useClipboard } from "./hooks/useClipboard"; @@ -265,6 +267,7 @@ export function StudioApp() { () => leftSidebarRef.current?.getTab() ?? "compositions", [], ); + const sdkSession = useSdkSession(projectId, activeCompPath); const domEditSession = useDomEditSession({ projectId, activeCompPath, @@ -314,6 +317,12 @@ export function StudioApp() { domEditSession.handleGsapRemoveKeyframe(a.id, p); } }; + useSdkSelectionSync( + sdkSession, + domEditSession.domEditSelection, + domEditSession.domEditGroupSelections, + ); + useCaptionDetection({ projectId, activeCompPath, @@ -419,17 +428,6 @@ export function StudioApp() { applyDomSelection: domEditSession.applyDomSelection, initialState: initialUrlStateRef.current, }); - const { jobs, isRendering, deleteRender, clearCompleted, startRender } = renderQueue; - const stableRenderQueue = useMemo( - () => ({ - jobs, - isRendering, - deleteRender, - clearCompleted, - startRender: startRender as (options: unknown) => Promise, - }), - [jobs, isRendering, deleteRender, clearCompleted, startRender], - ); const studioCtxValue = buildStudioContextValue({ projectId: projectId!, activeCompPath, @@ -445,7 +443,7 @@ export function StudioApp() { editHistory, handleUndo: appHotkeys.handleUndo, handleRedo: appHotkeys.handleRedo, - renderQueue: stableRenderQueue, + renderQueue, compositionDimensions, waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, handlePreviewIframeRef, @@ -485,7 +483,7 @@ export function StudioApp() { refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime} inspectorButtonActive={inspectorButtonActive} inspectorPanelActive={inspectorPanelActive} - onExport={() => void renderQueue.startRender()} + onExport={() => void renderQueue.startRender(undefined)} /> {previewPersistence.domEditSaveQueuePaused && ( j.status === "rendering"), - }; + const isRendering = jobs.some((j) => j.status === "rendering"); + return useMemo( + () => ({ + jobs, + isRendering, + deleteRender, + clearCompleted, + startRender: startRender as (options: unknown) => Promise, + }), + [jobs, isRendering, deleteRender, clearCompleted, startRender], + ); } diff --git a/packages/studio/src/hooks/useSdkSelectionSync.ts b/packages/studio/src/hooks/useSdkSelectionSync.ts new file mode 100644 index 000000000..d36cfdabe --- /dev/null +++ b/packages/studio/src/hooks/useSdkSelectionSync.ts @@ -0,0 +1,25 @@ +import { useEffect } from "react"; +import type { Composition } from "@hyperframes/sdk"; +import type { DomEditSelection } from "../components/editor/domEditing"; + +function toHfIds(group: DomEditSelection[], primary: DomEditSelection | null): string[] { + const source = group.length > 0 ? group : primary ? [primary] : []; + return source.flatMap((s) => (s.hfId ? [s.hfId] : [])); +} + +/** + * Stage 7 Step 2 — mirrors Studio canvas selection into the SDK session. + * + * Calls session.setSelection(hfIds) whenever domEditSelection or + * domEditGroupSelections changes. Pure effect; no existing hook modified. + */ +export function useSdkSelectionSync( + session: Composition | null, + domEditSelection: DomEditSelection | null, + domEditGroupSelections: DomEditSelection[], +): void { + useEffect(() => { + if (!session) return; + session.setSelection(toHfIds(domEditGroupSelections, domEditSelection)); + }, [session, domEditSelection, domEditGroupSelections]); +} diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts new file mode 100644 index 000000000..d5181c67c --- /dev/null +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -0,0 +1,58 @@ +import { useState, useEffect } from "react"; +import { openComposition } from "@hyperframes/sdk"; +import { createHttpAdapter } from "@hyperframes/sdk/adapters/http"; +import type { Composition } from "@hyperframes/sdk"; + +/** + * Stage 7 Step 1 — SDK session wired to the active composition. + * + * Creates an SDK Composition backed by createHttpAdapter on every + * (projectId, activeCompPath) change, disposes the old one on cleanup. + * The session is idle until Step 3 routes dispatch ops through it. + */ +export function useSdkSession( + projectId: string | null, + activeCompPath: string | null, +): Composition | null { + const [session, setSession] = useState(null); + + useEffect(() => { + if (!projectId || !activeCompPath) { + setSession(null); + return; + } + + let cancelled = false; + let comp: Composition | null = null; + + const adapter = createHttpAdapter({ + projectFilesUrl: `/api/projects/${projectId}`, + }); + adapter + .read(activeCompPath) + .then(async (content) => { + if (cancelled || typeof content !== "string") return; + comp = await openComposition(content, { persist: adapter }); + comp.on("persist:error", (e) => { + console.warn("[sdk] persist:error", e.error); + }); + // Cleanup may have fired while openComposition was awaited; dispose immediately. + if (cancelled) { + comp.dispose(); + return; + } + setSession(comp); + }) + .catch(() => { + if (!cancelled) setSession(null); + }); + + return () => { + cancelled = true; + const c = comp; + if (c) void c.flush().finally(() => c.dispose()); + }; + }, [projectId, activeCompPath]); + + return session; +}