mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
Add a small Studio selection channel so agents can ask a running preview server for the element the user selected in Studio. This keeps the UX on the existing npx hyperframes preview surface while giving agents a stable source file, target selector, timeline time, and thumbnail URL for follow-up edits.
103 lines
4.4 KiB
TypeScript
103 lines
4.4 KiB
TypeScript
import { useEffect, useRef, type MutableRefObject } from "react";
|
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
import { usePlayerStore } from "../player";
|
|
import { buildStudioSelectionSnapshot } from "../utils/studioSelectionSnapshot";
|
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
|
|
interface UseStudioSelectionPublisherParams {
|
|
projectId: string | null;
|
|
domEditSelection: DomEditSelection | null;
|
|
domEditSelectionRef: MutableRefObject<DomEditSelection | null>;
|
|
refreshKey: number;
|
|
previewDocumentVersion: number;
|
|
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
|
}
|
|
|
|
function reportSelectionPublishError(error: unknown): void {
|
|
if (error instanceof Error && error.name === "AbortError") return;
|
|
const errorName = error instanceof Error ? error.name : typeof error;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
trackStudioEvent("studio_selection_publish_failed", {
|
|
error_name: errorName,
|
|
error_message: errorMessage.slice(0, 500),
|
|
});
|
|
// eslint-disable-next-line no-console
|
|
console.warn("[Studio] Failed to update agent selection context", error);
|
|
}
|
|
|
|
function putSelection(projectId: string, selection: unknown, signal?: AbortSignal): Promise<void> {
|
|
return fetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ selection }),
|
|
signal,
|
|
}).then(() => undefined);
|
|
}
|
|
|
|
export function useStudioSelectionPublisher({
|
|
projectId,
|
|
domEditSelection,
|
|
domEditSelectionRef,
|
|
refreshKey,
|
|
previewDocumentVersion,
|
|
refreshDomEditSelectionFromPreview,
|
|
}: UseStudioSelectionPublisherParams): void {
|
|
const lastSelectionRefreshKeyRef = useRef(refreshKey);
|
|
const pendingSelectionRefreshKeyRef = useRef<number | null>(null);
|
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
useEffect(() => {
|
|
if (!projectId) return;
|
|
const selection = domEditSelection?.element.isConnected
|
|
? buildStudioSelectionSnapshot({
|
|
projectId,
|
|
selection: domEditSelection,
|
|
currentTime: usePlayerStore.getState().currentTime,
|
|
})
|
|
: null;
|
|
const controller = new AbortController();
|
|
void putSelection(projectId, selection, controller.signal).catch(reportSelectionPublishError);
|
|
return () => controller.abort();
|
|
}, [domEditSelection, projectId]);
|
|
|
|
// Clear server-side agent context when Studio leaves a project. Without this,
|
|
// a long-running multi-project preview server can keep serving the last
|
|
// selected element for a project after its tab/session unmounts.
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
useEffect(() => {
|
|
if (!projectId) return;
|
|
return () => {
|
|
void putSelection(projectId, null).catch(reportSelectionPublishError);
|
|
};
|
|
}, [projectId]);
|
|
|
|
// On external file edits, the iframe reloads while React keeps the previous
|
|
// DOM selection object alive. Clear the agent-facing snapshot immediately so
|
|
// `preview --context` never serves a detached or stale target, then let the
|
|
// post-load preview document refresh below re-resolve the selection if it
|
|
// still exists in the new document.
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
useEffect(() => {
|
|
if (lastSelectionRefreshKeyRef.current === refreshKey) return;
|
|
lastSelectionRefreshKeyRef.current = refreshKey;
|
|
pendingSelectionRefreshKeyRef.current = domEditSelectionRef.current ? refreshKey : null;
|
|
if (!projectId || !domEditSelectionRef.current) return;
|
|
const controller = new AbortController();
|
|
void putSelection(projectId, null, controller.signal).catch(reportSelectionPublishError);
|
|
return () => controller.abort();
|
|
}, [domEditSelectionRef, projectId, refreshKey]);
|
|
|
|
// `refreshPreviewDocumentVersion` ticks after iframe load and shortly after.
|
|
// Consume one pending refresh per external reload: enough to re-resolve the
|
|
// selected element once the new document is queryable, without republishing
|
|
// the same snapshot on every follow-up 80/300ms tick.
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
useEffect(() => {
|
|
if (pendingSelectionRefreshKeyRef.current === null) return;
|
|
pendingSelectionRefreshKeyRef.current = null;
|
|
const selection = domEditSelectionRef.current;
|
|
if (!selection) return;
|
|
void refreshDomEditSelectionFromPreview(selection);
|
|
}, [domEditSelectionRef, previewDocumentVersion, refreshDomEditSelectionFromPreview]);
|
|
}
|