mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
* 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
179 lines
6.1 KiB
TypeScript
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 */
|
|
}
|
|
}
|