Skip to content

Commit db4a9e0

Browse files
jrusso1020claude
andcommitted
feat(studio): render with preview variables + template handoff + docs
Sixth PR of the template-variables Studio stack — closing the loop from preview to render to developer handoff. - renders started from the Renders tab now carry the active preview variable overrides (StartRenderOptions.variables → POST /render → RenderConfig.variables), so "render" produces exactly what the user is previewing. - Variables panel "Use this template" footer: copy the effective values (defaults merged with overrides) as JSON, or as a ready-to-run `npx hyperframes render <comp> --variables '<json>'` command. - gitignore: negate the renders/ output rule for the tracked src/components/renders/ source dir — without it, pre-commit's format re-stage (`git add {staged_files}`) hard-fails on any change to those files. - docs: the Studio panel docs/concepts/variables.mdx described was aspirational — replace with the real Variables-in-Studio section (declare/edit, render-truthful preview, render-with-values, handoff, usage badges); document the new SDK variable APIs in docs/sdk/reference/composition.mdx (declaration ops, read APIs, setPreviewVariables). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent de151ad commit db4a9e0

7 files changed

Lines changed: 167 additions & 1 deletion

File tree

.fallowrc.jsonc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@
107107
// convention documented in CLAUDE.md. This is a namespace barrel, not a
108108
// collision.
109109
{ "file": "packages/cli/src/commands/*.ts", "exports": ["examples"] },
110+
// Options type for the render-queue hook's public startRender callback —
111+
// consumed structurally by callers (StudioRightPanel), not by import.
112+
{
113+
"file": "packages/studio/src/components/renders/useRenderQueue.ts",
114+
"exports": ["StartRenderOptions"],
115+
},
110116
// Independent ML model managers each declare their own DEFAULT_MODEL /
111117
// MODELS_DIR / ensureModel for their model namespace.
112118
{

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ output/
5151
renders/
5252
!packages/producer/tests/*/output/
5353
!packages/producer/tests/distributed/*/output/
54+
# Source dir that the renders/ output rule accidentally shadows (its files are
55+
# tracked; without this, pre-commit's format re-stage fails on them).
56+
!packages/studio/src/components/renders/
5457

5558
# Composition source media (large binaries)
5659
compositions/**/*.mp4

docs/concepts/variables.mdx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,25 @@ const { variables } = extractCompositionMetadata(html);
300300
// variables is CompositionVariable[]
301301
```
302302

303-
This is the same API the Studio editing UI uses to build the variables panel for each composition.
303+
This is the same API the Studio Variables panel uses to build its editor for each composition.
304+
305+
## Variables in Studio
306+
307+
The Studio's **Variables** tab (right inspector panel) is a full UI over this
308+
system:
309+
310+
- **Declare and edit** — add, edit, and remove declarations without touching the
311+
HTML by hand; edits persist into `data-composition-variables` with undo support.
312+
- **Preview with values** — type-appropriate inputs write ephemeral overrides that
313+
are injected into the preview as `window.__hfVariables`, exactly like render-time
314+
injection, so what you preview is what `--variables` renders. A header pill shows
315+
whether you're previewing defaults or custom values.
316+
- **Render with values** — renders started from the Renders tab carry the active
317+
preview overrides.
318+
- **Handoff** — copy the effective values as JSON or as a ready-to-run
319+
`hyperframes render --variables` command.
320+
- **Usage** — declarations no script reads are badged `unused`; ids read by scripts
321+
but missing from the schema get a one-click Declare action.
304322

305323
## Next Steps
306324

docs/sdk/reference/composition.mdx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,59 @@ comp.setVariableValue("brandFont", { name: "Inter", source: "https://fonts.googl
110110
comp.setVariableValue("heroImage", { url: "/assets/hero.jpg", fit: "cover" });
111111
```
112112

113+
### `declareVariable` / `updateVariableDeclaration` / `removeVariableDeclaration`
114+
115+
```typescript
116+
declareVariable(declaration: CompositionVariable): void
117+
updateVariableDeclaration(id: string, declaration: CompositionVariable): void
118+
removeVariableDeclaration(id: string): void
119+
```
120+
121+
Manage the `data-composition-variables` schema itself. `declareVariable` adds a new
122+
typed declaration (creating the attribute when the composition has none);
123+
`updateVariableDeclaration` replaces an existing declaration wholesale — the id is
124+
immutable, rename by remove + declare; `removeVariableDeclaration` deletes a
125+
declaration (the last removal drops the whole attribute). All three validate via
126+
`can()` (`E_DUPLICATE_VARIABLE`, `E_VARIABLE_NOT_FOUND`, `E_INVALID_ARGS`,
127+
`E_FRAGMENT_COMPOSITION` — fragment sources have no `<html>` to carry the schema).
128+
129+
```typescript
130+
comp.declareVariable({ id: "title", type: "string", label: "Title", default: "Hello" });
131+
comp.updateVariableDeclaration("title", { id: "title", type: "string", label: "Headline", default: "Hello" });
132+
comp.removeVariableDeclaration("title");
133+
```
134+
135+
### `getVariableDeclarations` / `getVariableValues` / `validateVariableValues` / `getVariableUsage`
136+
137+
```typescript
138+
getVariableDeclarations(): CompositionVariable[]
139+
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown>
140+
validateVariableValues(values: Record<string, unknown>): VariableValidationIssue[]
141+
getVariableUsage(): VariableUsageReport
142+
```
143+
144+
Read-only variable APIs (no dispatch). `getVariableDeclarations` returns the typed
145+
schema (same strict filter the render pipeline uses). `getVariableValues` resolves
146+
values exactly like the runtime's `getVariables()` — declared defaults merged under
147+
the given overrides — so callers can predict what a composition script will read for
148+
a given `--variables` payload. `validateVariableValues` runs the same checks as
149+
`--strict-variables` (`undeclared` / `type-mismatch` / `enum-out-of-range`).
150+
`getVariableUsage` statically scans every inline script for `getVariables()` reads
151+
and cross-references the schema: `{ usedIds, unusedDeclarations, undeclaredReads,
152+
scanIncomplete }``scanIncomplete` flips when scripts access variables dynamically,
153+
making `usedIds` a lower bound.
154+
155+
### `setPreviewVariables`
156+
157+
```typescript
158+
setPreviewVariables(values: Record<string, unknown> | null): boolean
159+
```
160+
161+
Apply ephemeral variable values to the preview surface (never persisted — use
162+
`setVariableValue` to change a default). Pass `null` to restore declared defaults.
163+
Delegates to the preview adapter's optional `setPreviewVariables`; returns `false`
164+
when no adapter support is available.
165+
113166
### `getElementTimings`
114167

115168
```typescript

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { SlideshowPanel } from "./panels/SlideshowPanel";
1616
import type { SceneInfo } from "./panels/SlideshowPanel";
1717
import { VariablesPanel } from "./panels/VariablesPanel";
1818
import { PanelTabButton } from "./PanelTabButton";
19+
import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
1920
import type { RenderJob } from "./renders/useRenderQueue";
2021
import type { BlockParam } from "@hyperframes/core/registry";
2122
import type { IframeWindow } from "../player/lib/playbackTypes";
@@ -421,6 +422,9 @@ export function StudioRightPanel({
421422
format,
422423
resolution,
423424
composition,
425+
// Render what the user is previewing: active variable overrides
426+
// from the Variables panel ride along (undefined = defaults).
427+
variables: usePreviewVariablesStore.getState().values ?? undefined,
424428
});
425429
}}
426430
compositionDimensions={compositionDimensions}

packages/studio/src/components/panels/VariablesPanel.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@ import {
1717
EMPTY_DRAFT,
1818
} from "./VariablesDeclarationForm";
1919
import { PreviewValueControl } from "./VariablesValueControls";
20+
import { copyTextToClipboard } from "../../utils/clipboard";
2021
import { isScalarVariableValue as isScalar } from "@hyperframes/core/variables";
2122

23+
/** POSIX single-quote escaping so the copied command survives quotes in values. */
24+
function shellSingleQuote(value: string): string {
25+
return `'${value.replace(/'/g, `'\\''`)}'`;
26+
}
27+
2228
interface VariablesPanelProps {
2329
sdkSession: Composition | null;
2430
reloadPreview: () => void;
@@ -209,6 +215,45 @@ function PreviewModeHeader({
209215
);
210216
}
211217

218+
/**
219+
* Developer/agent handoff: copy the effective values as JSON or as a
220+
* ready-to-run render command mirroring exactly what the preview shows.
221+
*/
222+
function HandoffFooter({
223+
effectiveValues,
224+
compPath,
225+
onCopy,
226+
}: {
227+
effectiveValues: Record<string, unknown>;
228+
compPath: string;
229+
onCopy: (text: string, what: string) => void;
230+
}) {
231+
const json = JSON.stringify(effectiveValues);
232+
const command = `npx hyperframes render ${shellSingleQuote(compPath)} --variables ${shellSingleQuote(json)}`;
233+
return (
234+
<div className="space-y-1.5 rounded-lg border border-neutral-800/70 bg-neutral-900/40 p-2">
235+
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
236+
Use this template
237+
</p>
238+
<code className="block truncate font-mono text-[9px] text-neutral-500" title={command}>
239+
{command}
240+
</code>
241+
<div className="flex items-center gap-2">
242+
<RowAction
243+
label="Copy render command"
244+
title="CLI command rendering exactly what the preview shows"
245+
onClick={() => onCopy(command, "Render command")}
246+
/>
247+
<RowAction
248+
label="Copy values JSON"
249+
title="Effective values (defaults merged with preview overrides)"
250+
onClick={() => onCopy(json, "Values JSON")}
251+
/>
252+
</div>
253+
</div>
254+
);
255+
}
256+
212257
const EMPTY_STATE = (
213258
<p className="text-[10px] leading-relaxed text-neutral-500">
214259
No variables declared. Variables make parts of this composition dynamic — declare them here (or
@@ -269,6 +314,24 @@ export const VariablesPanel = memo(function VariablesPanel({
269314
// eslint-disable-next-line react-hooks/exhaustive-deps
270315
[sdkSession, previewValues, refreshKey, revision],
271316
);
317+
const effectiveValues = useMemo(
318+
() => sdkSession?.getVariableValues(previewValues ?? undefined) ?? {},
319+
// eslint-disable-next-line react-hooks/exhaustive-deps
320+
[sdkSession, previewValues, refreshKey, revision],
321+
);
322+
323+
const copyToClipboard = useCallback(
324+
(text: string, what: string) => {
325+
// Shared helper carries the execCommand fallback Safari needs.
326+
void copyTextToClipboard(text).then((ok) =>
327+
showToast(
328+
ok ? `${what} copied` : `Couldn't copy ${what.toLowerCase()}`,
329+
ok ? "info" : "error",
330+
),
331+
);
332+
},
333+
[showToast],
334+
);
272335

273336
const dropPreviewOverride = useCallback(
274337
(id: string) => {
@@ -424,6 +487,13 @@ export const VariablesPanel = memo(function VariablesPanel({
424487
Scripts access variables dynamically — usage info may be incomplete.
425488
</p>
426489
)}
490+
{declarations.length > 0 && (
491+
<HandoffFooter
492+
effectiveValues={effectiveValues}
493+
compPath={activeCompPath ?? "index.html"}
494+
onCopy={copyToClipboard}
495+
/>
496+
)}
427497
{addOpen ? (
428498
<DeclarationForm
429499
initial={EMPTY_DRAFT}

packages/studio/src/components/renders/useRenderQueue.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export interface StartRenderOptions {
3434
resolution?: ResolutionPreset | "auto";
3535
/** Render a specific composition file instead of index.html. */
3636
composition?: string;
37+
/**
38+
* Composition-variable overrides ({variableId: value}), forwarded to the
39+
* render route and injected as window.__hfVariables — the same channel
40+
* `hyperframes render --variables` uses.
41+
*/
42+
variables?: Record<string, unknown>;
3743
}
3844

3945
// "Hide" (formerly "Clear") is a view operation, not a delete: hidden ids are
@@ -126,7 +132,9 @@ export function useRenderQueue(projectId: string | null) {
126132
}, [loadRenders]);
127133

128134
// Start a render and track progress via SSE
135+
// Pre-existing branchy fetch/poll flow — the variables passthrough added one branch.
129136
const startRender = useCallback(
137+
// fallow-ignore-next-line complexity
130138
async (opts: StartRenderOptions = {}) => {
131139
if (!projectId) return;
132140

@@ -154,6 +162,7 @@ export function useRenderQueue(projectId: string | null) {
154162
format: string;
155163
resolution?: string;
156164
composition?: string;
165+
variables?: Record<string, unknown>;
157166
telemetryDistinctId: string;
158167
} = {
159168
fps,
@@ -166,6 +175,9 @@ export function useRenderQueue(projectId: string | null) {
166175
};
167176
if (resolution && resolution !== "auto") body.resolution = resolution;
168177
if (composition) body.composition = composition;
178+
if (opts.variables && Object.keys(opts.variables).length > 0) {
179+
body.variables = opts.variables;
180+
}
169181
let res: Response;
170182
try {
171183
res = await fetch(`/api/projects/${projectId}/render`, {

0 commit comments

Comments
 (0)