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 <[email protected]>
This commit is contained in:
James
2026-07-09 13:31:03 -07:00
co-authored by Claude Fable 5
parent f633d59582
commit 80b4357a41
7 changed files with 153 additions and 14 deletions
+6
View File
@@ -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.
{
+3
View File
@@ -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
+19 -1
View File
@@ -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
+39 -13
View File
@@ -140,35 +140,61 @@ for (const v of comp.listVariables()) {
}
```
### `declareVariable`
### `declareVariable` / `updateVariableDeclaration` / `removeVariableDeclaration`
```typescript
declareVariable(decl: CompositionVariable): void
declareVariable(declaration: CompositionVariable): void
updateVariableDeclaration(id: string, declaration: CompositionVariable): void
removeVariableDeclaration(id: string): void
```
Create a new variable declaration, or fully replace an existing one (type, label, default, and all other schema fields — not just the value). This is the only path that creates the schema entry from scratch; `setVariableValue` intentionally refuses to.
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 `<html>` to carry the schema).
```typescript
comp.declareVariable({
id: "brandColor",
type: "color",
label: "Brand color",
default: "#6C5CE7",
});
comp.declareVariable({ id: "title", type: "string", label: "Title", default: "Hello" });
comp.updateVariableDeclaration("title", { id: "title", type: "string", label: "Headline", default: "Hello" });
comp.removeVariableDeclaration("title");
```
### `removeVariable`
<Note>`removeVariable(id)` is a shorthand alias of `removeVariableDeclaration(id)`.</Note>
### `getVariableDeclarations` / `getVariableValues` / `validateVariableValues` / `getVariableUsage`
```typescript
removeVariable(id: string): void
getVariableDeclarations(): CompositionVariable[]
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown>
validateVariableValues(values: Record<string, unknown>): VariableValidationIssue[]
getVariableUsage(): VariableUsageReport
```
Remove a variable's declaration entirely. Any live `var.{id}` overrides and `data-var-*` DOM references are left untouched — this only removes the schema entry.
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
comp.removeVariable("brandColor");
setPreviewVariables(values: Record<string, unknown> | 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
@@ -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}
@@ -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<string, unknown>;
compPath: string;
onCopy: (text: string, what: string) => void;
}) {
const json = JSON.stringify(effectiveValues);
const command = `npx hyperframes render ${shellSingleQuote(compPath)} --variables ${shellSingleQuote(json)}`;
return (
<div className="space-y-1.5 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">
Use this template
</p>
<code className="block truncate font-mono text-[9px] text-neutral-500" title={command}>
{command}
</code>
<div className="flex items-center gap-2">
<RowAction
label="Copy render command"
title="CLI command rendering exactly what the preview shows"
onClick={() => onCopy(command, "Render command")}
/>
<RowAction
label="Copy values JSON"
title="Effective values (defaults merged with preview overrides)"
onClick={() => onCopy(json, "Values JSON")}
/>
</div>
</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
@@ -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) => {
@@ -424,6 +487,13 @@ export const VariablesPanel = memo(function VariablesPanel({
Scripts access variables dynamically — usage info may be incomplete.
</p>
)}
{declarations.length > 0 && (
<HandoffFooter
effectiveValues={effectiveValues}
compPath={activeCompPath ?? "index.html"}
onCopy={copyToClipboard}
/>
)}
{addOpen ? (
<DeclarationForm
initial={EMPTY_DRAFT}
@@ -34,6 +34,12 @@ export interface StartRenderOptions {
resolution?: ResolutionPreset | "auto";
/** Render a specific composition file instead of index.html. */
composition?: string;
/**
* Composition-variable overrides ({variableId: value}), forwarded to the
* render route and injected as window.__hfVariables — the same channel
* `hyperframes render --variables` uses.
*/
variables?: Record<string, unknown>;
}
// "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<string, unknown>;
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`, {