feat(studio): stage 7 step 1 — wire SDK session into Studio (#1443)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(studio): flush in-flight http writes before disposing SDK session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* 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 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-15 14:00:39 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Miguel Ángel
parent 30ce1f35a2
commit 92e2c8ce6a
6 changed files with 108 additions and 24 deletions
+1 -3
View File
@@ -34,10 +34,8 @@ export type { PersistQueueModule, PersistQueueOptions } from "./persist-queue.js
export type { PersistAdapter, PreviewAdapter, PersistVersionEntry } from "./adapters/types.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 { createMemoryAdapter } from "./adapters/memory.js";
export { createHeadlessAdapter } from "./adapters/headless.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 { createHttpAdapter } from "./adapters/http.js";
export type { HttpAdapterOptions } from "./adapters/http.js"; export type { HttpAdapterOptions } from "./adapters/http.js";
+1
View File
@@ -39,6 +39,7 @@
"@codemirror/view": "6.40.0", "@codemirror/view": "6.40.0",
"@hyperframes/core": "workspace:*", "@hyperframes/core": "workspace:*",
"@hyperframes/player": "workspace:*", "@hyperframes/player": "workspace:*",
"@hyperframes/sdk": "workspace:*",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"bpm-detective": "^2.0.5", "bpm-detective": "^2.0.5",
"mediabunny": "^1.45.3" "mediabunny": "^1.45.3"
+11 -13
View File
@@ -13,6 +13,8 @@ import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
import { useTimelineEditing } from "./hooks/useTimelineEditing"; import { useTimelineEditing } from "./hooks/useTimelineEditing";
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
import { useDomEditSession } from "./hooks/useDomEditSession"; import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSession } from "./hooks/useSdkSession";
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard"; import { useClipboard } from "./hooks/useClipboard";
@@ -265,6 +267,7 @@ export function StudioApp() {
() => leftSidebarRef.current?.getTab() ?? "compositions", () => leftSidebarRef.current?.getTab() ?? "compositions",
[], [],
); );
const sdkSession = useSdkSession(projectId, activeCompPath);
const domEditSession = useDomEditSession({ const domEditSession = useDomEditSession({
projectId, projectId,
activeCompPath, activeCompPath,
@@ -314,6 +317,12 @@ export function StudioApp() {
domEditSession.handleGsapRemoveKeyframe(a.id, p); domEditSession.handleGsapRemoveKeyframe(a.id, p);
} }
}; };
useSdkSelectionSync(
sdkSession,
domEditSession.domEditSelection,
domEditSession.domEditGroupSelections,
);
useCaptionDetection({ useCaptionDetection({
projectId, projectId,
activeCompPath, activeCompPath,
@@ -419,17 +428,6 @@ export function StudioApp() {
applyDomSelection: domEditSession.applyDomSelection, applyDomSelection: domEditSession.applyDomSelection,
initialState: initialUrlStateRef.current, initialState: initialUrlStateRef.current,
}); });
const { jobs, isRendering, deleteRender, clearCompleted, startRender } = renderQueue;
const stableRenderQueue = useMemo(
() => ({
jobs,
isRendering,
deleteRender,
clearCompleted,
startRender: startRender as (options: unknown) => Promise<void>,
}),
[jobs, isRendering, deleteRender, clearCompleted, startRender],
);
const studioCtxValue = buildStudioContextValue({ const studioCtxValue = buildStudioContextValue({
projectId: projectId!, projectId: projectId!,
activeCompPath, activeCompPath,
@@ -445,7 +443,7 @@ export function StudioApp() {
editHistory, editHistory,
handleUndo: appHotkeys.handleUndo, handleUndo: appHotkeys.handleUndo,
handleRedo: appHotkeys.handleRedo, handleRedo: appHotkeys.handleRedo,
renderQueue: stableRenderQueue, renderQueue,
compositionDimensions, compositionDimensions,
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
handlePreviewIframeRef, handlePreviewIframeRef,
@@ -485,7 +483,7 @@ export function StudioApp() {
refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime} refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
inspectorButtonActive={inspectorButtonActive} inspectorButtonActive={inspectorButtonActive}
inspectorPanelActive={inspectorPanelActive} inspectorPanelActive={inspectorPanelActive}
onExport={() => void renderQueue.startRender()} onExport={() => void renderQueue.startRender(undefined)}
/> />
{previewPersistence.domEditSaveQueuePaused && ( {previewPersistence.domEditSaveQueuePaused && (
<SaveQueuePausedBanner <SaveQueuePausedBanner
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { trackStudioRenderStart } from "../../telemetry/events"; import { trackStudioRenderStart } from "../../telemetry/events";
export interface RenderJob { export interface RenderJob {
@@ -238,11 +238,15 @@ export function useRenderQueue(projectId: string | null) {
}; };
}, [projectId]); }, [projectId]);
return { const isRendering = jobs.some((j) => j.status === "rendering");
jobs, return useMemo(
startRender, () => ({
deleteRender, jobs,
clearCompleted, isRendering,
isRendering: jobs.some((j) => j.status === "rendering"), deleteRender,
}; clearCompleted,
startRender: startRender as (options: unknown) => Promise<void>,
}),
[jobs, isRendering, deleteRender, clearCompleted, startRender],
);
} }
@@ -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]);
}
@@ -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<Composition | null>(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;
}