Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/sdk/src/engine/apply-patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,20 @@ function applyVariableDefault(document: Document, id: string, newDefault: unknow
export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void {
const patches: JsonPatchOp[] = [];
const rootId = findRoot(parsed.document)?.getAttribute("data-hf-id") ?? null;
for (const [key, value] of Object.entries(overrides)) {
// Whole-declaration snapshots (varDecl.{id}) must replay BEFORE value keys
// (var.{id}): a declaration snapshot embeds the default at fold time, while
// var.{id} always carries the latest value — insertion order alone would let
// an older snapshot clobber a newer value.
const entries = Object.entries(overrides).sort(([a], [b]) => {
const aVar = a.startsWith("var.");
const bVar = b.startsWith("var.");
const aDecl = a.startsWith("varDecl.");
const bDecl = b.startsWith("varDecl.");
if (aVar && bDecl) return 1;
if (aDecl && bVar) return -1;
return 0; // stable — every other key keeps its insertion order
});
for (const [key, value] of entries) {
const path = keyToPath(key);
if (!path) continue;
if (value === null) {
Expand Down
36 changes: 10 additions & 26 deletions packages/sdk/src/engine/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,37 +869,21 @@ function handleSetVariableValue(
const modelPath = variablePath(id);
const oldVarDefault = readVariableDefault(parsed.document, id);

if (isObjectVariableValue(value)) {
// Object values (font / image): write to JSON model only — objects are not
// valid CSS custom property values (LOCKED §7).
writeVariableDefault(parsed.document, id, value);
const p = valueChange(modelPath, oldVarDefault ?? null, value);
return { forward: [p.forward], inverse: [p.inverse] };
}

// Scalar values: update the JSON model (B1 — drives the runtime) and also
// keep the CSS custom prop as secondary / compat for compositions that
// CSS-bind directly to --{id}.
const cssVar = `--${id}`;
const rootId = root.getAttribute("data-hf-id");
const oldStyles = getElementStyles(root);
const oldCssValue = oldStyles[cssVar] ?? null;
const newVal = String(value);
setElementStyles(root, { [cssVar]: newVal });
// Update the JSON model (B1 — drives the runtime) and keep the CSS custom
// prop as secondary / compat for compositions that CSS-bind directly to
// --{id}. Object values (font / image) are not valid CSS custom property
// values (LOCKED §7) — cssCompatChange clears any stale scalar prop instead.
// Emitting separate model + style patches keeps apply-patches.ts pure per
// path type, so inverse patches restore the exact pre-call state.
writeVariableDefault(parsed.document, id, value);

// Emit explicit patches for both the JSON model (canonical) and the CSS compat
// prop. Keeping them separate means apply-patches.ts can handle each path type
// purely (variable path → model only; style path → CSS only), so inverse patches
// correctly restore the exact pre-call state without CSS-side-effect ambiguity.
const modelP = valueChange(modelPath, oldVarDefault ?? null, value);
const forward: JsonPatchOp[] = [modelP.forward];
const inverse: JsonPatchOp[] = [modelP.inverse];

if (rootId) {
const cssPatch = scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal);
forward.push(cssPatch.forward);
inverse.push(cssPatch.inverse);
const css = cssCompatChange(parsed, id, isObjectVariableValue(value) ? null : String(value));
if (css) {
forward.push(css.forward);
inverse.push(css.inverse);
}

return { forward, inverse };
Expand Down
31 changes: 23 additions & 8 deletions packages/sdk/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,16 @@ class CompositionImpl implements Composition {
return validateVariables(values, this.getVariableDeclarations());
}

/**
* Script scans are content-keyed (same rationale as _gsapLabelCache): the
* panel recomputes usage on every preview reload, and unchanged script text
* is the common case — never pay a second acorn parse for identical input.
*/
private _variableUsageScanCache = new Map<string, ReturnType<typeof scanVariableUsage>>();

// Scan/merge dispatcher — same complexity class as the suppressed
// variableUsage.ts classifiers it drives.
// fallow-ignore-next-line complexity
getVariableUsage(): VariableUsageReport {
const usedIds: string[] = [];
const seen = new Set<string>();
Expand Down Expand Up @@ -217,14 +227,7 @@ class CompositionImpl implements Composition {
// The CSS compat channel counts as usage: a variable consumed only via
// var(--id) in stylesheets or inline styles must not be badged unused
// (removing it also removes the --{id} root prop and breaks the binding).
const cssParts: string[] = [];
for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) {
cssParts.push(styleEl.textContent ?? "");
}
for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) {
cssParts.push(el.getAttribute("style") ?? "");
}
const cssText = cssParts.join("\n");
const cssText = this._collectCssText();
const cssUsed = (id: string) => cssText.includes(`var(--${id}`);
return {
usedIds,
Expand All @@ -234,6 +237,18 @@ class CompositionImpl implements Composition {
};
}

/** All stylesheet + inline-style text, for var(--id) consumption checks. */
private _collectCssText(): string {
const cssParts: string[] = [];
for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) {
cssParts.push(styleEl.textContent ?? "");
}
for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) {
cssParts.push(el.getAttribute("style") ?? "");
}
return cssParts.join("\n");
}

setPreviewVariables(values: Record<string, unknown> | null): boolean {
if (!this.preview?.setPreviewVariables) return false;
this.preview.setPreviewVariables(values);
Expand Down
11 changes: 11 additions & 0 deletions packages/studio-server/src/helpers/variablesPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Shared shape check for composition-variable payloads (`?variables=` on the
* preview routes, `body.variables` on the render route) — one contract, one
* error string, so the routes can't drift.
*/

export const VARIABLES_PAYLOAD_ERROR = "variables must be a JSON object of {variableId: value}";

export function isVariablesPayload(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
1 change: 1 addition & 0 deletions packages/studio-server/src/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "../helpers/studioMotionRenderScript.js";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";

const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
Expand Down
1 change: 1 addition & 0 deletions packages/studio-server/src/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { StudioApiAdapter, RenderJobState } from "../types.js";
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers";
import { parseFps } from "@hyperframes/core";
import { resolveWithinProject } from "../helpers/safePath.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";

const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);

Expand Down
33 changes: 11 additions & 22 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ 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 { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
Expand Down Expand Up @@ -81,7 +82,7 @@ export function StudioApp() {
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
const [compositionLoading, setCompositionLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
const cropModeProps = useCropModeProps();
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
Expand All @@ -107,22 +108,6 @@ export function StudioApp() {
: 0;
return Math.max(timelineDuration, maxEnd);
}, [timelineDuration, timelineElements]);
const refreshTimersRef = useRef<number[]>([]);
const refreshPreviewDocumentVersion = useCallback(() => {
for (const id of refreshTimersRef.current) clearTimeout(id);
refreshTimersRef.current = [];
setPreviewDocumentVersion((v) => v + 1);
refreshTimersRef.current.push(
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80),
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300),
);
}, []);
useEffect(
() => () => {
for (const id of refreshTimersRef.current) clearTimeout(id);
},
[],
);
const [timelineVisible, setTimelineVisible] = useState(
() =>
initialUrlStateRef.current.timelineVisible ??
Expand Down Expand Up @@ -152,7 +137,11 @@ export function StudioApp() {
domEditSaveTimestampRef,
setRefreshKey,
});
const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef);
const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
projectId,
activeCompPath,
domEditSaveTimestampRef,
);
useEffect(() => {
if (activeCompPathHydrated) return;
if (!fileManager.fileTreeLoaded) return;
Expand Down Expand Up @@ -188,7 +177,7 @@ export function StudioApp() {
pendingTimelineEditPathRef,
uploadProjectFiles: fileManager.uploadProjectFiles,
isRecordingRef: isGestureRecordingRef,
sdkSession: sdkHandle.session,
sdkSession: editFlowSdkSession,
forceReloadSdkSession: sdkHandle.forceReload,
});
const {
Expand Down Expand Up @@ -304,7 +293,7 @@ export function StudioApp() {
openSourceForSelection: fileManager.openSourceForSelection,
selectSidebarTab: sidebarTabRef.current.select,
getSidebarTab: sidebarTabRef.current.get,
sdkSession: sdkHandle.session,
sdkSession: editFlowSdkSession,
forceReloadSdkSession: sdkHandle.forceReload,
});
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
Expand All @@ -322,7 +311,7 @@ export function StudioApp() {
}
};
useSdkSelectionSync(
sdkHandle.session,
editFlowSdkSession,
domEditSession.domEditSelection,
domEditSession.domEditGroupSelections,
);
Expand Down
27 changes: 27 additions & 0 deletions packages/studio/src/hooks/usePreviewDocumentVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useCallback, useEffect, useRef, useState } from "react";

/**
* Version counter for the preview DOM. `refresh` bumps immediately and again
* at 80ms / 300ms so consumers re-scan after the iframe settles; pending
* timers are collapsed by each new refresh and cleared on unmount.
*/
export function usePreviewDocumentVersion(): [number, () => void] {
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
const refreshTimersRef = useRef<number[]>([]);
const refresh = useCallback(() => {
for (const id of refreshTimersRef.current) clearTimeout(id);
refreshTimersRef.current = [];
setPreviewDocumentVersion((v) => v + 1);
refreshTimersRef.current.push(
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80),
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300),
);
}, []);
useEffect(
() => () => {
for (const id of refreshTimersRef.current) clearTimeout(id);
},
[],
);
return [previewDocumentVersion, refresh];
}
32 changes: 32 additions & 0 deletions packages/studio/src/hooks/useStudioSdkSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useEffect, type MutableRefObject } from "react";
import { useSdkSession } from "./useSdkSession";
import { usePreviewVariablesStore } from "./previewVariablesStore";

/**
* Open the studio's SDK session with master-view semantics.
*
* The master view has no explicit comp path, but the session must still model
* the project's main composition so schema-level panels (Variables, Slideshow)
* work there. Edit-flow consumers keep the legacy "no session on master view"
* gating via `editFlowSdkSession` so cutover behavior is unchanged.
*
* Also clears preview variable overrides whenever the composition or project
* changes — overrides are per-composition and must never leak into another
* composition's preview or render.
*/
export function useStudioSdkSessions(
projectId: string | null,
activeCompPath: string | null,
domEditSaveTimestampRef: MutableRefObject<number>,
) {
const sdkHandle = useSdkSession(
projectId,
activeCompPath ?? "index.html",
domEditSaveTimestampRef,
);
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
useEffect(() => {
usePreviewVariablesStore.getState().setValues(null);
}, [projectId, activeCompPath]);
return { sdkHandle, editFlowSdkSession };
}
4 changes: 3 additions & 1 deletion packages/studio/src/utils/studioUrlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface StudioUrlState {
selection: StudioUrlSelectionState | null;
}

const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders"];
const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "variables"];

export function normalizeStudioUrlPanelTab(
tab: RightPanelTab | null,
Expand Down Expand Up @@ -106,6 +106,8 @@ export function readStudioUrlStateFromWindow(): StudioUrlState {
return parseStudioUrlStateFromHash(window.location.hash);
}

// Pre-existing param-assembly complexity — surfaced by this PR's line shifts.
// fallow-ignore-next-line complexity
export function buildStudioHash(projectId: string, state: StudioUrlState): string {
const params = new URLSearchParams();

Expand Down
Loading