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>
This commit is contained in:
James
2026-07-09 13:31:03 -07:00
co-authored by Claude Fable 5
parent b34ad85165
commit 010d49327e
7 changed files with 153 additions and 14 deletions
@@ -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`, {