Files
hyperframes/packages/studio/src/utils/studioUiPreferences.ts
T
Miguel Ángel 94da403d6d feat(studio): expose Studio's live state to an agentic browser (WebMCP) (#3511)
* feat(studio): expose Studio's live state to an agentic browser

Registers a `studio_look` tool on `document.modelContext`, so an agent in a
browser that supports it can read what Studio knows: the open project and
composition, the playhead, the human's current selection with its
capabilities, and the timeline's elements with a handle for each.

The API is `document.modelContext`, not `navigator.modelContext`. The latter
is a polyfill compatibility shim rather than a spec member, so feature
detecting it is wrong even where a published sample appears to work.

Three decisions worth knowing:

Registration happens ONCE per mount, with the dependencies held in a ref that
every render refreshes. Depending on the handlers instead re-runs on nearly
every interaction, because the DomEdit actions object changes identity with
the selection and the element list. Each re-run aborts the registration signal
and unregisters everything, and the spec warns that a quick unregister-then-
reregister can apply an old call's arguments against the new schema. The test
for this is the important one in the unit; breaking the empty dependency array
fails it and nothing else.

Tools resolve with a tagged result, they never reject. That is forced by the
spec: a rejected `execute` has its reason discarded and the caller sees a bare
UnknownError, so rejecting would guarantee the agent cannot learn why an edit
failed.

Elements are addressed by a minted handle, not by `TimelineElement.id`. That
id is a synthesised identity, so `getElementById` misses most elements; the
handle carries `data-hf-id`, else the DOM id, else a selector plus occurrence.

Mounted from `EditorShell` rather than `App`, because the DomEdit contexts are
only readable below `DomEditProvider` and `App.tsx` is three lines under the
600-line cap.

The undo signal is reported as the shell actually exposes it, `canUndo` and a
label, rather than as a revision counter. The depth lives in component-local
state and is not reachable without plumbing it through the shell context, so
the field says what it is instead of implying precision it does not have.

Writes are not in this change. `canWrite` is optimistic and the comment says
so; the write tools need a real guard against the paused-save and external-
conflict states, which are not on any context this component can reach yet.

* fix(studio): bound WebMCP look filters

* fix(studio): remove premature WebMCP write state

* docs(studio): name WebMCP singleton assumption

* fix(studio): surface WebMCP registration failures
2026-08-26 23:59:49 -04:00

179 lines
6.1 KiB
TypeScript

export interface StoredPreviewZoomState {
zoomPercent: number;
panX: number;
panY: number;
}
export type TimelineTimeDisplayMode = "time" | "frame";
export interface StudioUiPreferences {
leftCollapsed?: boolean;
leftWidth?: number;
rightWidth?: number;
timelineVisible?: boolean;
timelineHeight?: number;
playbackRate?: number;
audioMuted?: boolean;
audioVolume?: number;
thumbnailMode?: "adaptive" | "hidden";
previewZoom?: StoredPreviewZoomState;
recentBlocks?: string[];
snapEnabled?: boolean;
gridVisible?: boolean;
gridSpacing?: number;
snapToGrid?: boolean;
/** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
timelineSnapEnabled?: boolean;
/** Transport + ruler readout mode: timecode or frame number. */
timeDisplayMode?: TimelineTimeDisplayMode;
/**
* Timeline zoom mode. Persisted so a zoom PINNED on the first edit survives the
* post-edit iframe reload — otherwise the store reset to "fit" and the duration
* change rescaled every clip (the blink-fix's rescale symptom).
*/
timelineZoomMode?: "fit" | "manual";
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
timelineManualZoomPercent?: number;
/**
* Expose Studio's editing capabilities to an agentic browser as WebMCP tools.
* Absent means on: the browser still gates every actual call behind its own
* permission prompt, so "registered" is not "reachable without consent".
* Changes take effect on the next Studio reload because registration is
* intentionally scoped to one mount.
*/
agentToolsEnabled?: boolean;
}
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getBrowserStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
// fallow-ignore-next-line complexity
function readStorage(storage: Storage | null): StudioUiPreferences {
if (!storage) return {};
try {
const raw = storage.getItem(STUDIO_UI_PREFERENCES_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return {};
const preferences: StudioUiPreferences = {};
if (typeof parsed.leftCollapsed === "boolean") {
preferences.leftCollapsed = parsed.leftCollapsed;
}
if (typeof parsed.leftWidth === "number" && Number.isFinite(parsed.leftWidth)) {
preferences.leftWidth = parsed.leftWidth;
}
if (typeof parsed.rightWidth === "number" && Number.isFinite(parsed.rightWidth)) {
preferences.rightWidth = parsed.rightWidth;
}
if (typeof parsed.timelineVisible === "boolean") {
preferences.timelineVisible = parsed.timelineVisible;
}
if (typeof parsed.timelineHeight === "number" && Number.isFinite(parsed.timelineHeight)) {
preferences.timelineHeight = parsed.timelineHeight;
}
if (typeof parsed.playbackRate === "number" && Number.isFinite(parsed.playbackRate)) {
preferences.playbackRate = parsed.playbackRate;
}
if (typeof parsed.audioMuted === "boolean") {
preferences.audioMuted = parsed.audioMuted;
}
if (
typeof parsed.audioVolume === "number" &&
Number.isFinite(parsed.audioVolume) &&
parsed.audioVolume >= 0 &&
parsed.audioVolume <= 1
) {
preferences.audioVolume = parsed.audioVolume;
}
if (parsed.thumbnailMode === "adaptive" || parsed.thumbnailMode === "hidden") {
preferences.thumbnailMode = parsed.thumbnailMode;
} else if (typeof parsed.thumbnailsEnabled === "boolean") {
preferences.thumbnailMode = parsed.thumbnailsEnabled ? "adaptive" : "hidden";
}
if (isRecord(parsed.previewZoom)) {
const { zoomPercent, panX, panY } = parsed.previewZoom;
if (
typeof zoomPercent === "number" &&
Number.isFinite(zoomPercent) &&
typeof panX === "number" &&
Number.isFinite(panX) &&
typeof panY === "number" &&
Number.isFinite(panY)
) {
preferences.previewZoom = { zoomPercent, panX, panY };
}
}
if (Array.isArray(parsed.recentBlocks)) {
preferences.recentBlocks = parsed.recentBlocks.filter(
(v: unknown): v is string => typeof v === "string",
);
}
if (typeof parsed.snapEnabled === "boolean") {
preferences.snapEnabled = parsed.snapEnabled;
}
if (typeof parsed.gridVisible === "boolean") {
preferences.gridVisible = parsed.gridVisible;
}
if (typeof parsed.gridSpacing === "number" && Number.isFinite(parsed.gridSpacing)) {
preferences.gridSpacing = parsed.gridSpacing;
}
if (typeof parsed.snapToGrid === "boolean") {
preferences.snapToGrid = parsed.snapToGrid;
}
if (typeof parsed.timelineSnapEnabled === "boolean") {
preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
}
if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
preferences.timeDisplayMode = parsed.timeDisplayMode;
}
if (parsed.timelineZoomMode === "fit" || parsed.timelineZoomMode === "manual") {
preferences.timelineZoomMode = parsed.timelineZoomMode;
}
if (
typeof parsed.timelineManualZoomPercent === "number" &&
Number.isFinite(parsed.timelineManualZoomPercent)
) {
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
}
if (typeof parsed.agentToolsEnabled === "boolean") {
preferences.agentToolsEnabled = parsed.agentToolsEnabled;
}
return preferences;
} catch {
return {};
}
}
export function readStudioUiPreferences(storage: Storage | null = getBrowserStorage()) {
return readStorage(storage);
}
export function writeStudioUiPreferences(
patch: StudioUiPreferences,
storage: Storage | null = getBrowserStorage(),
) {
if (!storage) return;
try {
const next = {
...readStorage(storage),
...patch,
};
storage.setItem(STUDIO_UI_PREFERENCES_KEY, JSON.stringify(next));
} catch {
/* localStorage may be unavailable or full */
}
}