diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 102ec89027..0c09507e34 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -107,6 +107,12 @@ // convention documented in CLAUDE.md. This is a namespace barrel, not a // collision. { "file": "packages/cli/src/commands/*.ts", "exports": ["examples"] }, + // Options type for the render-queue hook's public startRender callback — + // consumed structurally by callers (StudioRightPanel), not by import. + { + "file": "packages/studio/src/components/renders/useRenderQueue.ts", + "exports": ["StartRenderOptions"], + }, // Independent ML model managers each declare their own DEFAULT_MODEL / // MODELS_DIR / ensureModel for their model namespace. { diff --git a/.gitignore b/.gitignore index 77f369bd90..4803fe6958 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ output/ renders/ !packages/producer/tests/*/output/ !packages/producer/tests/distributed/*/output/ +# Source dir that the renders/ output rule accidentally shadows (its files are +# tracked; without this, pre-commit's format re-stage fails on them). +!packages/studio/src/components/renders/ # Composition source media (large binaries) compositions/**/*.mp4 diff --git a/docs/concepts/variables.mdx b/docs/concepts/variables.mdx index 99e8f4d26e..9d4fc0fd72 100644 --- a/docs/concepts/variables.mdx +++ b/docs/concepts/variables.mdx @@ -300,7 +300,25 @@ const { variables } = extractCompositionMetadata(html); // variables is CompositionVariable[] ``` -This is the same API the Studio editing UI uses to build the variables panel for each composition. +This is the same API the Studio Variables panel uses to build its editor for each composition. + +## Variables in Studio + +The Studio's **Variables** tab (right inspector panel) is a full UI over this +system: + +- **Declare and edit** — add, edit, and remove declarations without touching the + HTML by hand; edits persist into `data-composition-variables` with undo support. +- **Preview with values** — type-appropriate inputs write ephemeral overrides that + are injected into the preview as `window.__hfVariables`, exactly like render-time + injection, so what you preview is what `--variables` renders. A header pill shows + whether you're previewing defaults or custom values. +- **Render with values** — renders started from the Renders tab carry the active + preview overrides. +- **Handoff** — copy the effective values as JSON or as a ready-to-run + `hyperframes render --variables` command. +- **Usage** — declarations no script reads are badged `unused`; ids read by scripts + but missing from the schema get a one-click Declare action. ## Next Steps diff --git a/docs/sdk/reference/composition.mdx b/docs/sdk/reference/composition.mdx index b24bdd30f3..3651211402 100644 --- a/docs/sdk/reference/composition.mdx +++ b/docs/sdk/reference/composition.mdx @@ -110,6 +110,59 @@ comp.setVariableValue("brandFont", { name: "Inter", source: "https://fonts.googl comp.setVariableValue("heroImage", { url: "/assets/hero.jpg", fit: "cover" }); ``` +### `declareVariable` / `updateVariableDeclaration` / `removeVariableDeclaration` + +```typescript +declareVariable(declaration: CompositionVariable): void +updateVariableDeclaration(id: string, declaration: CompositionVariable): void +removeVariableDeclaration(id: string): void +``` + +Manage the `data-composition-variables` schema itself. `declareVariable` adds a new +typed declaration (creating the attribute when the composition has none); +`updateVariableDeclaration` replaces an existing declaration wholesale — the id is +immutable, rename by remove + declare; `removeVariableDeclaration` deletes a +declaration (the last removal drops the whole attribute). All three validate via +`can()` (`E_DUPLICATE_VARIABLE`, `E_VARIABLE_NOT_FOUND`, `E_INVALID_ARGS`, +`E_FRAGMENT_COMPOSITION` — fragment sources have no `` to carry the schema). + +```typescript +comp.declareVariable({ id: "title", type: "string", label: "Title", default: "Hello" }); +comp.updateVariableDeclaration("title", { id: "title", type: "string", label: "Headline", default: "Hello" }); +comp.removeVariableDeclaration("title"); +``` + +### `getVariableDeclarations` / `getVariableValues` / `validateVariableValues` / `getVariableUsage` + +```typescript +getVariableDeclarations(): CompositionVariable[] +getVariableValues(overrides?: Record): Record +validateVariableValues(values: Record): VariableValidationIssue[] +getVariableUsage(): VariableUsageReport +``` + +Read-only variable APIs (no dispatch). `getVariableDeclarations` returns the typed +schema (same strict filter the render pipeline uses). `getVariableValues` resolves +values exactly like the runtime's `getVariables()` — declared defaults merged under +the given overrides — so callers can predict what a composition script will read for +a given `--variables` payload. `validateVariableValues` runs the same checks as +`--strict-variables` (`undeclared` / `type-mismatch` / `enum-out-of-range`). +`getVariableUsage` statically scans every inline script for `getVariables()` reads +and cross-references the schema: `{ usedIds, unusedDeclarations, undeclaredReads, +scanIncomplete }` — `scanIncomplete` flips when scripts access variables dynamically, +making `usedIds` a lower bound. + +### `setPreviewVariables` + +```typescript +setPreviewVariables(values: Record | null): boolean +``` + +Apply ephemeral variable values to the preview surface (never persisted — use +`setVariableValue` to change a default). Pass `null` to restore declared defaults. +Delegates to the preview adapter's optional `setPreviewVariables`; returns `false` +when no adapter support is available. + ### `getElementTimings` ```typescript diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index ef08ddec2a..b87669da70 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -16,6 +16,7 @@ import { SlideshowPanel } from "./panels/SlideshowPanel"; import type { SceneInfo } from "./panels/SlideshowPanel"; import { VariablesPanel } from "./panels/VariablesPanel"; import { PanelTabButton } from "./PanelTabButton"; +import { usePreviewVariablesStore } from "../hooks/previewVariablesStore"; import type { RenderJob } from "./renders/useRenderQueue"; import type { BlockParam } from "@hyperframes/core/registry"; import type { IframeWindow } from "../player/lib/playbackTypes"; @@ -421,6 +422,9 @@ export function StudioRightPanel({ format, resolution, composition, + // Render what the user is previewing: active variable overrides + // from the Variables panel ride along (undefined = defaults). + variables: usePreviewVariablesStore.getState().values ?? undefined, }); }} compositionDimensions={compositionDimensions} diff --git a/packages/studio/src/components/panels/VariablesPanel.tsx b/packages/studio/src/components/panels/VariablesPanel.tsx index b8630c3e50..5d781c5957 100644 --- a/packages/studio/src/components/panels/VariablesPanel.tsx +++ b/packages/studio/src/components/panels/VariablesPanel.tsx @@ -17,8 +17,14 @@ import { EMPTY_DRAFT, } from "./VariablesDeclarationForm"; import { PreviewValueControl } from "./VariablesValueControls"; +import { copyTextToClipboard } from "../../utils/clipboard"; import { isScalarVariableValue as isScalar } from "@hyperframes/core/variables"; +/** POSIX single-quote escaping so the copied command survives quotes in values. */ +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + interface VariablesPanelProps { sdkSession: Composition | null; reloadPreview: () => void; @@ -209,6 +215,45 @@ function PreviewModeHeader({ ); } +/** + * Developer/agent handoff: copy the effective values as JSON or as a + * ready-to-run render command mirroring exactly what the preview shows. + */ +function HandoffFooter({ + effectiveValues, + compPath, + onCopy, +}: { + effectiveValues: Record; + compPath: string; + onCopy: (text: string, what: string) => void; +}) { + const json = JSON.stringify(effectiveValues); + const command = `npx hyperframes render ${shellSingleQuote(compPath)} --variables ${shellSingleQuote(json)}`; + return ( +
+

+ Use this template +

+ + {command} + +
+ onCopy(command, "Render command")} + /> + onCopy(json, "Values JSON")} + /> +
+
+ ); +} + const EMPTY_STATE = (

No variables declared. Variables make parts of this composition dynamic — declare them here (or @@ -269,6 +314,24 @@ export const VariablesPanel = memo(function VariablesPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps [sdkSession, previewValues, refreshKey, revision], ); + const effectiveValues = useMemo( + () => sdkSession?.getVariableValues(previewValues ?? undefined) ?? {}, + // eslint-disable-next-line react-hooks/exhaustive-deps + [sdkSession, previewValues, refreshKey, revision], + ); + + const copyToClipboard = useCallback( + (text: string, what: string) => { + // Shared helper carries the execCommand fallback Safari needs. + void copyTextToClipboard(text).then((ok) => + showToast( + ok ? `${what} copied` : `Couldn't copy ${what.toLowerCase()}`, + ok ? "info" : "error", + ), + ); + }, + [showToast], + ); const dropPreviewOverride = useCallback( (id: string) => { @@ -410,6 +473,13 @@ export const VariablesPanel = memo(function VariablesPanel({ Scripts access variables dynamically — usage info may be incomplete.

)} + {declarations.length > 0 && ( + + )} {addOpen ? ( ; } // "Hide" (formerly "Clear") is a view operation, not a delete: hidden ids are @@ -126,7 +132,9 @@ export function useRenderQueue(projectId: string | null) { }, [loadRenders]); // Start a render and track progress via SSE + // Pre-existing branchy fetch/poll flow — the variables passthrough added one branch. const startRender = useCallback( + // fallow-ignore-next-line complexity async (opts: StartRenderOptions = {}) => { if (!projectId) return; @@ -154,6 +162,7 @@ export function useRenderQueue(projectId: string | null) { format: string; resolution?: string; composition?: string; + variables?: Record; telemetryDistinctId: string; } = { fps, @@ -166,6 +175,9 @@ export function useRenderQueue(projectId: string | null) { }; if (resolution && resolution !== "auto") body.resolution = resolution; if (composition) body.composition = composition; + if (opts.variables && Object.keys(opts.variables).length > 0) { + body.variables = opts.variables; + } let res: Response; try { res = await fetch(`/api/projects/${projectId}/render`, {