Merge pull request #2050 from heygen-com/07-07-feat_studio_variables_inspector_panel_with_live_preview_values

feat(studio): variables inspector panel with live preview values
This commit is contained in:
James Russo
2026-07-09 13:42:44 -07:00
committed by GitHub
12 changed files with 1311 additions and 59 deletions
+8
View File
@@ -27,6 +27,14 @@ export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
export type {
CompositionVariable,
CompositionVariableType,
CompositionVariableBase,
StringVariable,
NumberVariable,
ColorVariable,
BooleanVariable,
EnumVariable,
FontVariable,
ImageVariable,
VariableValidationIssue,
VariableUsageScan,
} from "@hyperframes/core/variables";
@@ -0,0 +1,31 @@
import { Tooltip } from "./ui";
/** Tab-bar button for the right inspector panel header. */
export function PanelTabButton({
label,
tooltip,
active,
onClick,
}: {
label: string;
tooltip: string;
active: boolean;
onClick: () => void;
}) {
return (
<Tooltip label={tooltip} side="bottom">
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
active
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
}`}
>
{label}
</button>
</Tooltip>
);
}
@@ -7,7 +7,6 @@ import {
type MutableRefObject,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Tooltip } from "./ui";
import { PropertyPanel } from "./editor/PropertyPanel";
import { LayersPanel } from "./editor/LayersPanel";
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
@@ -15,6 +14,8 @@ import { BlockParamsPanel } from "./editor/BlockParamsPanel";
import { RenderQueue } from "./renders/RenderQueue";
import { SlideshowPanel } from "./panels/SlideshowPanel";
import type { SceneInfo } from "./panels/SlideshowPanel";
import { VariablesPanel } from "./panels/VariablesPanel";
import { PanelTabButton } from "./PanelTabButton";
import type { RenderJob } from "./renders/useRenderQueue";
import type { BlockParam } from "@hyperframes/core/registry";
import type { IframeWindow } from "../player/lib/playbackTypes";
@@ -461,64 +462,38 @@ export function StudioRightPanel({
<div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
{STUDIO_INSPECTOR_PANELS_ENABLED && (
<>
<Tooltip label="Element styles and properties" side="bottom">
<button
type="button"
onClick={() => handleInspectorPaneButtonClick("design")}
aria-pressed={designPaneOpen}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
designPaneOpen
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
}`}
>
Design
</button>
</Tooltip>
<Tooltip label="Composition layer stack" side="bottom">
<button
type="button"
onClick={() => handleInspectorPaneButtonClick("layers")}
aria-pressed={layersPaneOpen}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
layersPaneOpen
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
}`}
>
Layers
</button>
</Tooltip>
<PanelTabButton
label="Design"
tooltip="Element styles and properties"
active={designPaneOpen}
onClick={() => handleInspectorPaneButtonClick("design")}
/>
<PanelTabButton
label="Layers"
tooltip="Composition layer stack"
active={layersPaneOpen}
onClick={() => handleInspectorPaneButtonClick("layers")}
/>
</>
)}
<Tooltip label="Render queue and exports" side="bottom">
<button
type="button"
onClick={() => setRightPanelTab("renders")}
aria-pressed={rightPanelTab === "renders"}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
rightPanelTab === "renders"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
}`}
>
{renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
</button>
</Tooltip>
<Tooltip label="Slideshow branching editor" side="bottom">
<button
type="button"
onClick={() => setRightPanelTab("slideshow")}
aria-pressed={rightPanelTab === "slideshow"}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
rightPanelTab === "slideshow"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
}`}
>
Slideshow
</button>
</Tooltip>
<PanelTabButton
label={renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
tooltip="Render queue and exports"
active={rightPanelTab === "renders"}
onClick={() => setRightPanelTab("renders")}
/>
<PanelTabButton
label="Slideshow"
tooltip="Slideshow branching editor"
active={rightPanelTab === "slideshow"}
onClick={() => setRightPanelTab("slideshow")}
/>
<PanelTabButton
label="Variables"
tooltip="Template variables — declare, preview with values"
active={rightPanelTab === "variables"}
onClick={() => setRightPanelTab("variables")}
/>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
{rightPanelTab === "block-params" && activeBlockParams ? (
@@ -535,6 +510,13 @@ export function StudioRightPanel({
onPersist={onPersistSlideshow}
onPersistNotes={onPersistSlideshowNotes}
/>
) : rightPanelTab === "variables" ? (
<VariablesPanel
sdkSession={sdkSession}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={recordEdit}
/>
) : layersPaneOpen && designPaneOpen ? (
<div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
<div
@@ -0,0 +1,135 @@
/**
* Pure form-logic guards for the Variables declaration form:
* mergeDeclarationEdit (unmodeled-key passthrough on same-type edits) and
* declarationFromDraft (per-type parsing + validation).
*/
import { describe, it, expect } from "vitest";
import {
mergeDeclarationEdit,
declarationFromDraft,
draftFromDeclaration,
EMPTY_DRAFT,
} from "./VariablesDeclarationForm.js";
import type { CompositionVariable } from "@hyperframes/core/variables";
describe("mergeDeclarationEdit", () => {
it("preserves unmodeled keys on a same-type edit", () => {
// The form owns id/label/type/default/description/min/max/step/options; every
// other key (source, brandRole, unit, maxLength, …) must ride through a Save.
const original = {
id: "brand",
type: "font",
label: "Brand font",
default: "Inter",
source: "brand-kit",
default_name: "Inter",
brandRole: "heading",
} as unknown as CompositionVariable;
const edited = {
id: "brand",
type: "font",
label: "Brand heading font",
default: "Inter",
} as CompositionVariable;
const merged = mergeDeclarationEdit(original, edited) as Record<string, unknown>;
expect(merged.label).toBe("Brand heading font");
expect(merged.source).toBe("brand-kit");
expect(merged.default_name).toBe("Inter");
expect(merged.brandRole).toBe("heading");
});
it("drops old type-specific metadata when the type changes", () => {
const original = {
id: "count",
type: "number",
label: "Count",
default: 3,
min: 0,
max: 10,
} as CompositionVariable;
const edited = {
id: "count",
type: "string",
label: "Count",
default: "3",
} as CompositionVariable;
const merged = mergeDeclarationEdit(original, edited) as Record<string, unknown>;
expect(merged).toEqual(edited);
expect(merged.min).toBeUndefined();
expect(merged.max).toBeUndefined();
});
});
describe("declarationFromDraft", () => {
it("requires a non-empty id", () => {
expect(declarationFromDraft({ ...EMPTY_DRAFT, id: " " })).toBe("Variable id is required.");
});
it("parses a string variable verbatim and defaults label to id", () => {
const decl = declarationFromDraft({ ...EMPTY_DRAFT, id: "title", defaultRaw: "Hello" });
expect(decl).toEqual({ id: "title", label: "title", type: "string", default: "Hello" });
});
it("rejects a non-numeric number default", () => {
expect(
declarationFromDraft({ ...EMPTY_DRAFT, id: "n", type: "number", defaultRaw: "abc" }),
).toBe("Default must be a number.");
});
it("parses a number variable with min/max/step and drops blank constraints", () => {
const decl = declarationFromDraft({
...EMPTY_DRAFT,
id: "n",
type: "number",
defaultRaw: "5",
min: "0",
max: "",
step: "0.5",
});
expect(decl).toEqual({ id: "n", label: "n", type: "number", default: 5, min: 0, step: 0.5 });
});
it("rejects an enum with no options and an off-list default", () => {
expect(declarationFromDraft({ ...EMPTY_DRAFT, id: "e", type: "enum", optionsRaw: "" })).toBe(
"Enum needs at least one option (one per line, value:Label).",
);
expect(
declarationFromDraft({
...EMPTY_DRAFT,
id: "e",
type: "enum",
optionsRaw: "wide:Wide\ntall:Tall",
defaultRaw: "square",
}),
).toBe("Default must be one of the options.");
});
it("parses a boolean from the 'true' sentinel", () => {
expect(
declarationFromDraft({ ...EMPTY_DRAFT, id: "b", type: "boolean", defaultRaw: "true" }),
).toMatchObject({
type: "boolean",
default: true,
});
expect(
declarationFromDraft({ ...EMPTY_DRAFT, id: "b", type: "boolean", defaultRaw: "" }),
).toMatchObject({
default: false,
});
});
it("round-trips a declaration through draftFromDeclaration → declarationFromDraft", () => {
const original: CompositionVariable = {
id: "count",
type: "number",
label: "Count",
default: 3,
min: 0,
max: 10,
};
expect(declarationFromDraft(draftFromDeclaration(original))).toEqual(original);
});
});
@@ -0,0 +1,321 @@
/**
* Add/edit form for a variable declaration. Builds a typed
* CompositionVariable from free-text drafts; structural validation beyond
* the field-level checks here is the SDK's job (can() on dispatch).
*/
import { useState } from "react";
import type { CompositionVariable, CompositionVariableType } from "@hyperframes/sdk";
import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls";
const VARIABLE_TYPES: CompositionVariableType[] = [
"string",
"number",
"color",
"boolean",
"enum",
"font",
"image",
];
export interface DeclarationDraft {
id: string;
label: string;
type: CompositionVariableType;
defaultRaw: string;
description: string;
min: string;
max: string;
step: string;
optionsRaw: string;
}
export const EMPTY_DRAFT: DeclarationDraft = {
id: "",
label: "",
type: "string",
defaultRaw: "",
description: "",
min: "",
max: "",
step: "",
optionsRaw: "",
};
// Per-type field mapping — one ternary per optional field.
// fallow-ignore-next-line complexity
export function draftFromDeclaration(decl: CompositionVariable): DeclarationDraft {
const numeric = decl.type === "number" ? decl : null;
return {
...EMPTY_DRAFT,
id: decl.id,
label: decl.label,
type: decl.type,
defaultRaw: String(decl.default),
description: decl.description ?? "",
min: numeric?.min !== undefined ? String(numeric.min) : "",
max: numeric?.max !== undefined ? String(numeric.max) : "",
step: numeric?.step !== undefined ? String(numeric.step) : "",
optionsRaw:
decl.type === "enum" ? decl.options.map((o) => `${o.value}:${o.label}`).join("\n") : "",
};
}
function numberDeclFromDraft(
base: { id: string; label: string; description?: string },
draft: DeclarationDraft,
): CompositionVariable | string {
const value = Number(draft.defaultRaw);
if (!Number.isFinite(value)) return "Default must be a number.";
const constraint = (key: "min" | "max" | "step") => {
const raw = draft[key].trim();
if (!raw) return {};
const parsed = Number(raw);
return Number.isFinite(parsed) ? { [key]: parsed } : {};
};
return {
...base,
type: "number",
default: value,
...constraint("min"),
...constraint("max"),
...constraint("step"),
};
}
// fallow-ignore-next-line complexity
function enumDeclFromDraft(
base: { id: string; label: string; description?: string },
draft: DeclarationDraft,
): CompositionVariable | string {
const options = draft.optionsRaw
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [value, ...rest] = line.split(":");
const v = (value ?? "").trim();
return { value: v, label: rest.join(":").trim() || v };
})
.filter((o) => o.value.length > 0);
if (options.length === 0) return "Enum needs at least one option (one per line, value:Label).";
const value = draft.defaultRaw.trim() || (options[0]?.value ?? "");
if (!options.some((o) => o.value === value)) return "Default must be one of the options.";
return { ...base, type: "enum", default: value, options };
}
/**
* Fields the form actually models. On an unchanged-type edit, every OTHER key
* of the original declaration (font source/default_name/default_source,
* brandRole, placeholder, maxLength, unit, …) must ride through untouched —
* updateVariableDeclaration replaces wholesale, so dropping them here would
* silently strip schema metadata on every Edit + Save.
*/
const FORM_OWNED_KEYS = new Set([
"id",
"label",
"type",
"default",
"description",
"min",
"max",
"step",
"options",
]);
export function mergeDeclarationEdit(
original: CompositionVariable,
edited: CompositionVariable,
): CompositionVariable {
// Type changed → old type-specific metadata no longer applies.
if (original.type !== edited.type) return edited;
const passthrough: Record<string, unknown> = {};
for (const [key, value] of Object.entries(original)) {
if (!FORM_OWNED_KEYS.has(key)) passthrough[key] = value;
}
// Both sides are same-type declarations and edited owns every form key, so
// the merge preserves the declared shape.
return { ...passthrough, ...edited };
}
/** Build a typed declaration from the form draft; string on validation error. */
// fallow-ignore-next-line complexity
export function declarationFromDraft(draft: DeclarationDraft): CompositionVariable | string {
const id = draft.id.trim();
if (!id) return "Variable id is required.";
const label = draft.label.trim() || id;
const description = draft.description.trim() || undefined;
const base = { id, label, ...(description ? { description } : {}) };
switch (draft.type) {
case "number":
return numberDeclFromDraft(base, draft);
case "boolean":
return { ...base, type: "boolean", default: draft.defaultRaw.trim() === "true" };
case "enum":
return enumDeclFromDraft(base, draft);
default:
// string / color / font / image — string default, verbatim.
return { ...base, type: draft.type, default: draft.defaultRaw };
}
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<label className="text-[9px] font-medium text-neutral-500">{label}</label>
{children}
</div>
);
}
function DefaultField({
draft,
onChange,
}: {
draft: DeclarationDraft;
onChange: (defaultRaw: string) => void;
}) {
if (draft.type === "boolean") {
return (
<select
value={draft.defaultRaw === "true" ? "true" : "false"}
onChange={(e) => onChange(e.target.value)}
className={VARIABLES_INPUT_CLASS}
>
<option value="false">false</option>
<option value="true">true</option>
</select>
);
}
return (
<input
type="text"
value={draft.defaultRaw}
onChange={(e) => onChange(e.target.value)}
className={VARIABLES_INPUT_CLASS}
/>
);
}
export function DeclarationForm({
initial,
submitLabel,
onSubmit,
onCancel,
}: {
initial: DeclarationDraft;
submitLabel: string;
onSubmit: (decl: CompositionVariable) => void;
onCancel: () => void;
}) {
const [draft, setDraft] = useState<DeclarationDraft>(initial);
const [error, setError] = useState<string | null>(null);
const editingExisting = initial.id.length > 0;
const set = (patch: Partial<DeclarationDraft>) => setDraft((d) => ({ ...d, ...patch }));
const submit = () => {
const result = declarationFromDraft(draft);
if (typeof result === "string") {
setError(result);
return;
}
setError(null);
onSubmit(result);
};
return (
<div className="space-y-2 rounded-lg border border-neutral-800 bg-neutral-900/60 p-2">
<div className="grid grid-cols-2 gap-2">
<Field label="ID">
<input
type="text"
value={draft.id}
disabled={editingExisting}
onChange={(e) => set({ id: e.target.value })}
placeholder="title"
className={`${VARIABLES_INPUT_CLASS} font-mono disabled:opacity-50`}
/>
</Field>
<Field label="Label">
<input
type="text"
value={draft.label}
onChange={(e) => set({ label: e.target.value })}
placeholder="Title"
className={VARIABLES_INPUT_CLASS}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Type">
<select
value={draft.type}
onChange={(e) => {
const type = VARIABLE_TYPES.find((t) => t === e.target.value);
if (type) set({ type });
}}
className={VARIABLES_INPUT_CLASS}
>
{VARIABLE_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</Field>
<Field label="Default">
<DefaultField draft={draft} onChange={(defaultRaw) => set({ defaultRaw })} />
</Field>
</div>
{draft.type === "number" && (
<div className="grid grid-cols-3 gap-2">
{(["min", "max", "step"] as const).map((key) => (
<Field key={key} label={key}>
<input
type="text"
value={draft[key]}
onChange={(e) => set({ [key]: e.target.value })}
className={`${VARIABLES_INPUT_CLASS} tabular-nums`}
/>
</Field>
))}
</div>
)}
{draft.type === "enum" && (
<Field label="Options (one per line, value:Label)">
<textarea
value={draft.optionsRaw}
onChange={(e) => set({ optionsRaw: e.target.value })}
rows={3}
className={`${VARIABLES_INPUT_CLASS} resize-y font-mono`}
/>
</Field>
)}
<Field label="Description (optional)">
<input
type="text"
value={draft.description}
onChange={(e) => set({ description: e.target.value })}
className={VARIABLES_INPUT_CLASS}
/>
</Field>
{error && <p className="text-[10px] text-red-400">{error}</p>}
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={onCancel}
className="h-6 rounded px-2 text-[10px] text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
<button
type="button"
onClick={submit}
className="h-6 rounded bg-neutral-800 px-2 text-[10px] font-medium text-neutral-200 hover:bg-neutral-700"
>
{submitLabel}
</button>
</div>
</div>
);
}
@@ -0,0 +1,446 @@
import { memo, useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
import type {
Composition,
CompositionVariable,
VariableUsageReport,
VariableValidationIssue,
} from "@hyperframes/sdk";
import type { EditHistoryKind } from "../../utils/editHistory";
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
import { useVariablesPersist } from "../../hooks/useVariablesPersist";
import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
import {
DeclarationForm,
draftFromDeclaration,
mergeDeclarationEdit,
EMPTY_DRAFT,
} from "./VariablesDeclarationForm";
import { PreviewValueControl } from "./VariablesValueControls";
import { isScalarVariableValue as isScalar } from "@hyperframes/core/variables";
interface VariablesPanelProps {
sdkSession: Composition | null;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
}
function formatIssue(issue: VariableValidationIssue): string {
switch (issue.kind) {
case "undeclared":
return `"${issue.variableId}" is not declared.`;
case "type-mismatch":
return `"${issue.variableId}" expects ${issue.expected}, got ${issue.actual}.`;
case "enum-out-of-range":
return `"${issue.variableId}" must be one of: ${issue.allowed.join(", ")}.`;
}
}
function ValidationStrip({ issues }: { issues: VariableValidationIssue[] }) {
if (issues.length === 0) return null;
return (
<div className="space-y-1 rounded-lg border border-red-900/60 bg-red-950/30 p-2">
{issues.map((issue) => (
<p key={`${issue.kind}:${issue.variableId}`} className="text-[10px] text-red-300">
{formatIssue(issue)}
</p>
))}
</div>
);
}
function RowAction({
label,
title,
danger,
onClick,
}: {
label: string;
title: string;
danger?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
className={`h-5 rounded px-1.5 text-[9px] text-neutral-500 hover:bg-neutral-800 ${
danger ? "hover:text-red-400" : "hover:text-neutral-200"
}`}
>
{label}
</button>
);
}
// fallow-ignore-next-line complexity
function VariableRow({
decl,
value,
overridden,
unused,
editing,
onCommitPreview,
onSetDefault,
onToggleEdit,
onSaveEdit,
onRemove,
}: {
decl: CompositionVariable;
value: unknown;
overridden: boolean;
unused: boolean;
editing: boolean;
onCommitPreview: (value: unknown) => void;
onSetDefault: (value: string | number | boolean) => void;
onToggleEdit: () => void;
onSaveEdit: (decl: CompositionVariable) => void;
onRemove: () => void;
}) {
return (
<div className="space-y-1.5 rounded-lg border border-neutral-800/70 p-2">
<div className="flex items-center gap-1.5">
<span className="truncate text-[10px] font-medium text-neutral-300">{decl.label}</span>
<span className="rounded bg-neutral-800 px-1 py-px font-mono text-[8px] text-neutral-500">
{decl.type}
</span>
{unused && (
<span
className="rounded bg-amber-900/40 px-1 py-px text-[8px] text-amber-400"
title="No script reads this variable"
>
unused
</span>
)}
{overridden && <span className="h-1.5 w-1.5 rounded-full bg-studio-accent" />}
<span className="ml-auto flex items-center gap-1">
{overridden && isScalar(value) && (
<RowAction
label="Set default"
title="Persist this value as the declared default"
onClick={() => onSetDefault(value)}
/>
)}
<RowAction label="Edit" title="Edit declaration" onClick={onToggleEdit} />
<RowAction label="✕" title="Remove declaration" danger onClick={onRemove} />
</span>
</div>
{decl.description && <p className="text-[9px] text-neutral-500">{decl.description}</p>}
{editing ? (
<DeclarationForm
initial={draftFromDeclaration(decl)}
submitLabel="Save"
onSubmit={(edited) => onSaveEdit(mergeDeclarationEdit(decl, edited))}
onCancel={onToggleEdit}
/>
) : (
<PreviewValueControl decl={decl} value={value} onCommit={onCommitPreview} />
)}
</div>
);
}
function UndeclaredReads({
usage,
onDeclare,
}: {
usage: VariableUsageReport | null;
onDeclare: (id: string) => void;
}) {
if (!usage || usage.undeclaredReads.length === 0) return null;
return (
<div className="space-y-1 rounded-lg border border-neutral-800/70 bg-neutral-900/40 p-2">
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
Read by scripts, not declared
</p>
{usage.undeclaredReads.map((id) => (
<div key={id} className="flex items-center gap-2">
<code className="font-mono text-[10px] text-neutral-400">{id}</code>
<RowAction
label="Declare"
title="Declare as a string variable"
onClick={() => onDeclare(id)}
/>
</div>
))}
</div>
);
}
/** Preview-state pill + reset, shown in the panel header. */
function PreviewModeHeader({
overrideCount,
onReset,
}: {
overrideCount: number;
onReset: () => void;
}) {
const hasOverrides = overrideCount > 0;
return (
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-neutral-200">Variables</span>
<span
className={`rounded-full px-2 py-0.5 text-[9px] font-medium ${
hasOverrides
? "bg-studio-accent/20 text-studio-accent"
: "bg-neutral-800 text-neutral-500"
}`}
>
{hasOverrides ? `Previewing ${overrideCount} custom` : "Previewing defaults"}
</span>
</div>
{hasOverrides && (
<button
type="button"
onClick={onReset}
className="h-6 rounded px-2 text-[10px] text-neutral-400 hover:text-neutral-200"
>
Reset
</button>
)}
</div>
);
}
const EMPTY_STATE = (
<p className="text-[10px] leading-relaxed text-neutral-500">
No variables declared. Variables make parts of this composition dynamic declare them here (or
in <code className="font-mono">data-composition-variables</code>), read them with{" "}
<code className="font-mono">getVariables()</code>, and pass values at render time with{" "}
<code className="font-mono">--variables</code>.
</p>
);
// Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanel.
// fallow-ignore-next-line complexity
export const VariablesPanel = memo(function VariablesPanel({
sdkSession,
reloadPreview,
domEditSaveTimestampRef,
recordEdit,
}: VariablesPanelProps) {
const { activeCompPath, showToast } = useStudioShellContext();
const { refreshKey } = useStudioPlaybackContext();
const { readProjectFile, writeProjectFile } = useFileManagerContext();
const previewValues = usePreviewVariablesStore((s) => s.values);
const setPreviewValues = usePreviewVariablesStore((s) => s.setValues);
// Bumped after each persisted schema edit so declarations re-derive without
// waiting for the session reload round-trip.
const [revision, setRevision] = useState(0);
// Also bump on any session mutation (undo/redo, edits dispatched by other
// panels or agents) — the memos below must never trust refreshKey alone.
useEffect(() => {
if (!sdkSession) return;
return sdkSession.on("change", () => setRevision((r) => r + 1));
}, [sdkSession]);
const [addOpen, setAddOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const persistVariables = useVariablesPersist({
sdkSession,
activeCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
});
const declarations = useMemo(
() => sdkSession?.getVariableDeclarations() ?? [],
// eslint-disable-next-line react-hooks/exhaustive-deps
[sdkSession, refreshKey, revision],
);
const usage = useMemo(
() => sdkSession?.getVariableUsage() ?? null,
// eslint-disable-next-line react-hooks/exhaustive-deps
[sdkSession, refreshKey, revision],
);
const issues = useMemo(
() => (previewValues && sdkSession ? sdkSession.validateVariableValues(previewValues) : []),
// eslint-disable-next-line react-hooks/exhaustive-deps
[sdkSession, previewValues, refreshKey, revision],
);
const dropPreviewOverride = useCallback(
(id: string) => {
if (previewValues && id in previewValues) {
const next = { ...previewValues };
delete next[id];
setPreviewValues(next);
}
},
[previewValues, setPreviewValues],
);
const commitPreviewValue = useCallback(
(id: string, value: unknown, declDefault: unknown) => {
const next = { ...(previewValues ?? {}) };
if (JSON.stringify(value) === JSON.stringify(declDefault)) {
delete next[id];
} else {
next[id] = value;
}
setPreviewValues(next);
reloadPreview();
},
[previewValues, setPreviewValues, reloadPreview],
);
const runSchemaEdit = useCallback(
async (label: string, mutate: (session: Composition) => void): Promise<boolean> => {
try {
const changed = await persistVariables(label, mutate);
if (changed) setRevision((r) => r + 1);
else showToast(`${label}: no change applied`, "info");
return changed;
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
return false;
}
},
[persistVariables, showToast],
);
const handleAdd = useCallback(
(decl: CompositionVariable) => {
if (!sdkSession) return;
const check = sdkSession.can({ type: "declareVariable", declaration: decl });
if (!check.ok) {
showToast(check.message, "error");
return;
}
setAddOpen(false);
void runSchemaEdit(`Declare variable "${decl.id}"`, (s) => s.declareVariable(decl));
},
[sdkSession, runSchemaEdit, showToast],
);
const handleUpdate = useCallback(
(decl: CompositionVariable) => {
if (!sdkSession) return;
const check = sdkSession.can({
type: "updateVariableDeclaration",
id: decl.id,
declaration: decl,
});
if (!check.ok) {
showToast(check.message, "error");
return;
}
setEditingId(null);
void runSchemaEdit(`Edit variable "${decl.id}"`, (s) =>
s.updateVariableDeclaration(decl.id, decl),
);
},
[sdkSession, runSchemaEdit, showToast],
);
const handleRemove = useCallback(
(id: string) => {
if (!sdkSession) return;
const check = sdkSession.can({ type: "removeVariableDeclaration", id });
if (!check.ok) {
showToast(check.message, "error");
return;
}
// Drop the preview override only if the declaration was actually removed —
// otherwise a rejected/failed edit would leave the row on disk but silently
// wipe the user's custom preview value.
void runSchemaEdit(`Remove variable "${id}"`, (s) => s.removeVariableDeclaration(id)).then(
(changed) => {
if (changed) dropPreviewOverride(id);
},
);
},
[sdkSession, runSchemaEdit, dropPreviewOverride, showToast],
);
const handleSetDefault = useCallback(
(id: string, value: string | number | boolean) => {
void runSchemaEdit(`Set default for "${id}"`, (s) => s.setVariableValue(id, value));
// The override now equals the persisted default — drop it from preview state.
dropPreviewOverride(id);
},
[runSchemaEdit, dropPreviewOverride],
);
const resetPreview = useCallback(() => {
setPreviewValues(null);
reloadPreview();
}, [setPreviewValues, reloadPreview]);
if (!sdkSession) {
return (
<div className="flex h-full items-center justify-center px-6 text-center">
<p className="text-xs text-neutral-500">Open a composition to manage its variables.</p>
</div>
);
}
return (
<div className="flex h-full flex-col">
<PreviewModeHeader
overrideCount={previewValues ? Object.keys(previewValues).length : 0}
onReset={resetPreview}
/>
<div className="flex-1 space-y-3 overflow-y-auto p-3">
<ValidationStrip issues={issues} />
{declarations.length === 0 && !addOpen && EMPTY_STATE}
{/* fallow-ignore-next-line complexity */}
{declarations.map((decl) => (
<VariableRow
key={decl.id}
decl={decl}
value={
previewValues && decl.id in previewValues ? previewValues[decl.id] : decl.default
}
overridden={previewValues !== null && decl.id in previewValues}
unused={
usage !== null && !usage.scanIncomplete && usage.unusedDeclarations.includes(decl.id)
}
editing={editingId === decl.id}
onCommitPreview={(v) => commitPreviewValue(decl.id, v, decl.default)}
onSetDefault={(v) => handleSetDefault(decl.id, v)}
onToggleEdit={() => setEditingId(editingId === decl.id ? null : decl.id)}
onSaveEdit={handleUpdate}
onRemove={() => handleRemove(decl.id)}
/>
))}
<UndeclaredReads
usage={usage}
onDeclare={(id) => handleAdd({ id, type: "string", label: id, default: "" })}
/>
{usage?.scanIncomplete && (
<p className="text-[9px] text-neutral-600">
Scripts access variables dynamically usage info may be incomplete.
</p>
)}
{addOpen ? (
<DeclarationForm
initial={EMPTY_DRAFT}
submitLabel="Add variable"
onSubmit={handleAdd}
onCancel={() => setAddOpen(false)}
/>
) : (
<button
type="button"
onClick={() => setAddOpen(true)}
className="h-7 w-full rounded-lg border border-dashed border-neutral-800 text-[10px] font-medium text-neutral-500 transition-colors hover:border-neutral-700 hover:text-neutral-300"
>
+ Add variable
</button>
)}
</div>
</div>
);
});
@@ -0,0 +1,215 @@
/**
* Per-type preview-value inputs for the Variables panel. Text-like inputs
* draft locally and commit on blur/Enter so the preview doesn't reload per
* keystroke; discrete inputs (checkbox, select, color swatch, range) commit
* immediately.
*/
import { useState } from "react";
import type {
CompositionVariable,
ColorVariable,
EnumVariable,
NumberVariable,
} from "@hyperframes/sdk";
export const VARIABLES_INPUT_CLASS =
"w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700";
/** Text input that drafts locally and commits on blur/Enter. */
function DraftTextInput({
value,
onCommit,
type = "text",
className = VARIABLES_INPUT_CLASS,
maxLength,
placeholder,
min,
max,
step,
}: {
value: string;
onCommit: (raw: string) => void;
type?: "text" | "number";
className?: string;
maxLength?: number;
placeholder?: string;
min?: number;
max?: number;
step?: number;
}) {
const [draft, setDraft] = useState<string | null>(null);
return (
<input
type={type}
value={draft ?? value}
maxLength={maxLength}
placeholder={placeholder}
min={min}
max={max}
step={step}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft !== null && draft !== value) onCommit(draft);
setDraft(null);
}}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
className={className}
/>
);
}
function EnumControl({
decl,
current,
onCommit,
}: {
decl: EnumVariable;
current: unknown;
onCommit: (value: unknown) => void;
}) {
return (
<select
value={String(current)}
onChange={(e) => onCommit(e.target.value)}
className={VARIABLES_INPUT_CLASS}
>
{decl.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
function ColorControl({
current,
onCommit,
}: {
decl: ColorVariable;
current: unknown;
onCommit: (value: unknown) => void;
}) {
const colorValue = typeof current === "string" ? current : "#000000";
// The native picker fires change continuously while dragging the gradient;
// draft locally and commit once on close (blur) — each commit reloads the
// whole preview iframe.
const [draft, setDraft] = useState<string | null>(null);
return (
<div className="flex items-center gap-2">
<input
type="color"
value={draft ?? (/^#[0-9a-fA-F]{6}$/.test(colorValue) ? colorValue : "#000000")}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft !== null && draft !== colorValue) onCommit(draft);
setDraft(null);
}}
className="h-6 w-6 cursor-pointer rounded border border-neutral-700 bg-transparent"
/>
<DraftTextInput
value={colorValue}
onCommit={onCommit}
className={`${VARIABLES_INPUT_CLASS} flex-1 font-mono`}
/>
</div>
);
}
// fallow-ignore-next-line complexity
function NumberControl({
decl,
current,
onCommit,
}: {
decl: NumberVariable;
current: unknown;
onCommit: (value: unknown) => void;
}) {
const numberValue = typeof current === "number" ? current : Number(current) || 0;
const hasRange = decl.min !== undefined && decl.max !== undefined;
// Drag ticks stay local; commit once on release — each commit reloads the
// whole preview iframe, so per-tick commits would thrash it.
const [dragValue, setDragValue] = useState<number | null>(null);
const commitDrag = () => {
if (dragValue !== null && dragValue !== numberValue) onCommit(dragValue);
setDragValue(null);
};
const commitRaw = (raw: string) => {
const n = Number(raw);
onCommit(Number.isFinite(n) ? n : raw);
};
return (
<div className="flex items-center gap-2">
{hasRange && (
<input
type="range"
min={decl.min}
max={decl.max}
step={decl.step ?? 1}
value={dragValue ?? numberValue}
onChange={(e) => setDragValue(Number(e.target.value))}
onPointerUp={commitDrag}
onKeyUp={commitDrag}
onBlur={commitDrag}
className="flex-1"
/>
)}
<DraftTextInput
type="number"
value={String(numberValue)}
onCommit={commitRaw}
min={decl.min}
max={decl.max}
step={decl.step}
className={`${VARIABLES_INPUT_CLASS} ${hasRange ? "w-16" : "flex-1"} tabular-nums`}
/>
{decl.unit && <span className="text-[9px] text-neutral-500">{decl.unit}</span>}
</div>
);
}
// Per-type dispatcher — one branch per variable type, same shape as BlockParamsPanel.
// fallow-ignore-next-line complexity
export function PreviewValueControl({
decl,
value,
onCommit,
}: {
decl: CompositionVariable;
value: unknown;
onCommit: (value: unknown) => void;
}) {
const current = value === undefined ? decl.default : value;
switch (decl.type) {
case "boolean":
return (
<input
type="checkbox"
checked={current === true}
onChange={(e) => onCommit(e.target.checked)}
className="h-3.5 w-3.5 accent-neutral-400"
/>
);
case "enum":
return <EnumControl decl={decl} current={current} onCommit={onCommit} />;
case "color":
return <ColorControl decl={decl} current={current} onCommit={onCommit} />;
case "number":
return <NumberControl decl={decl} current={current} onCommit={onCommit} />;
default: {
// string / font (family name) / image (URL) — plain text input for v1.
const textValue = typeof current === "string" ? current : JSON.stringify(current);
return (
<DraftTextInput
value={textValue}
onCommit={onCommit}
maxLength={decl.type === "string" ? decl.maxLength : undefined}
placeholder={decl.type === "string" ? decl.placeholder : undefined}
/>
);
}
}
}
@@ -0,0 +1,34 @@
import { create } from "zustand";
/**
* Ephemeral composition-variable overrides for the preview iframe.
*
* Values here are NEVER persisted to the composition — they ride the preview
* URL as `?variables=<json>` (see the studio-server preview routes), which the
* server injects as `window.__hfVariables` exactly like render-time injection,
* so what the user previews is what `hyperframes render --variables` produces.
* `null` means "preview with declared defaults".
*/
interface PreviewVariablesState {
values: Record<string, unknown> | null;
setValues: (values: Record<string, unknown> | null) => void;
}
export const usePreviewVariablesStore = create<PreviewVariablesState>((set) => ({
values: null,
setValues: (values) => set({ values: values && Object.keys(values).length > 0 ? values : null }),
}));
/**
* Apply the current preview-variable overrides to a preview URL (both the
* Player's initial mount and refreshPlayer's soft reload route through this,
* so a hard remount can't silently drop the active overrides).
*/
export function applyPreviewVariablesToUrl(url: URL): void {
const values = usePreviewVariablesStore.getState().values;
if (values) {
url.searchParams.set("variables", JSON.stringify(values));
} else {
url.searchParams.delete("variables");
}
}
@@ -0,0 +1,61 @@
import { useCallback } from "react";
import type { Composition } from "@hyperframes/sdk";
import { persistSdkSerialize } from "../utils/sdkCutover";
import type { UseSlideshowPersistParams } from "./useSlideshowPersist";
/** Same single-writer dependency set the slideshow persist path uses. */
export type UseVariablesPersistParams = Omit<UseSlideshowPersistParams, "coalesceKey">;
/**
* Persist a variable-schema edit: run `mutate` (SDK declaration/value ops)
* against the session, then write the serialized composition through the
* standard single-writer path (undo history + self-write echo suppression +
* preview reload). Mutations that end up changing nothing are skipped, so a
* no-op dispatch (e.g. declaring a duplicate id) never pollutes undo history.
*/
export function useVariablesPersist({
sdkSession,
activeCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
}: UseVariablesPersistParams): (
label: string,
mutate: (session: Composition) => void,
) => Promise<boolean> {
return useCallback(
async (label: string, mutate: (session: Composition) => void) => {
if (!sdkSession) return false;
const path = activeCompPath ?? "index.html";
const originalContent = await readProjectFile(path);
mutate(sdkSession);
const after = sdkSession.serialize();
if (after === originalContent) return false;
await persistSdkSerialize(
after,
path,
originalContent,
{
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
},
{ label },
);
return true;
},
[
sdkSession,
activeCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
],
);
}
@@ -1,6 +1,7 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
import { useMountEffect } from "../../hooks/useMountEffect";
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
import { HyperframesLoader } from "../../components/ui";
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
// at module load, which throws under SSR. Defer the import to the mount effect
@@ -154,7 +155,12 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
// Create the web component imperatively to avoid JSX custom-element typing.
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
const src = directUrl || `/api/projects/${projectId}/preview`;
const srcUrl = new URL(
directUrl || `/api/projects/${projectId}/preview`,
window.location.origin,
);
applyPreviewVariablesToUrl(srcUrl);
const src = srcUrl.pathname + srcUrl.search;
player.setAttribute("shader-capture-scale", "1");
player.setAttribute("shader-loading", "player");
player.setAttribute("src", src);
@@ -44,6 +44,7 @@ import {
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
/**
* Whether the derived elements differ from the current ones in any field that
@@ -127,6 +128,8 @@ export function useTimelinePlayer() {
[setElements, setTimelineReady, setDuration],
);
// Pre-existing dispatcher complexity — surfaced by this PR's line shifts, not new logic.
// fallow-ignore-next-line complexity
const getAdapter = useCallback((): PlaybackAdapter | null => {
try {
const iframe = iframeRef.current;
@@ -209,6 +212,7 @@ export function useTimelinePlayer() {
}, []);
const startRAFLoop = useCallback(() => {
// fallow-ignore-next-line complexity
const tick = () => {
const adapter = getAdapter();
if (adapter) {
@@ -475,6 +479,7 @@ export function useTimelinePlayer() {
const src = iframe.src;
const url = new URL(src, window.location.origin);
url.searchParams.set("_t", String(Date.now()));
applyPreviewVariablesToUrl(url);
iframe.src = url.toString();
}, [saveSeekPosition]);
const getAdapterRef = useRef(getAdapter);
@@ -484,6 +489,8 @@ export function useTimelinePlayer() {
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
// Pre-existing message-router complexity — surfaced by line shifts, not new logic.
// fallow-ignore-next-line complexity
const handleMessage = (e: MessageEvent) => {
const data = e.data;
const ourIframe = iframeRef.current;
+7 -1
View File
@@ -13,7 +13,13 @@ export interface AppToast {
tone: "error" | "info";
}
export type RightPanelTab = "layers" | "design" | "renders" | "block-params" | "slideshow";
export type RightPanelTab =
| "layers"
| "design"
| "renders"
| "block-params"
| "slideshow"
| "variables";
export type RightInspectorPane = "layers" | "design";
export interface RightInspectorPanes {