feat(studio): per-composition render button in compositions tab (#874)

* feat(studio): add per-composition render button in compositions tab

Thread composition path through the full render pipeline so individual
compositions can be rendered independently from the studio UI.

- Add download icon button on each comp card (visible on hover)
- Accept `composition` field in POST /projects/:id/render
- Pass composition as `entryFile` to the producer's createRenderJob
- Make the Export button in the Renders panel composition-aware
  (renders the active composition instead of always index.html)

* fix(studio): make composition render buttons always visible

The hover-only opacity made them undiscoverable.

* fix(studio): address PR review — CLI adapter, path guard, a11y, tests, settings sync

- Wire `composition` → `entryFile` in CLI studio adapter (studioServer.ts)
  so `hyperframes preview` renders the correct composition, not always index.html
- Add path-traversal guard: reject composition paths that resolve outside projectDir
- Add `aria-label` to the icon-only render button for screen readers
- Add 4 tests: forwarding, empty/missing → undefined, path-traversal → 400
- Persist render settings (format/quality/fps) to localStorage so comp card
  buttons use the same settings as the Export panel

* refactor(studio): extract render settings persistence to own module

Move getPersistedRenderSettings/persistRenderSettings out of
RenderQueue.tsx into renderSettings.ts so code-splitting the
component doesn't drag along the helper.
This commit is contained in:
Miguel Ángel
2026-05-15 22:55:54 +02:00
committed by GitHub
parent 9b23ccf665
commit 4fd9520a90
12 changed files with 235 additions and 12 deletions
@@ -5,6 +5,8 @@ interface CompositionsTabProps {
compositions: string[];
activeComposition: string | null;
onSelect: (comp: string) => void;
onRenderComposition?: (comp: string) => void;
isRendering?: boolean;
}
const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
@@ -94,11 +96,15 @@ function CompCard({
comp,
isActive,
onSelect,
onRender,
isRendering,
}: {
projectId: string;
comp: string;
isActive: boolean;
onSelect: () => void;
onRender?: () => void;
isRendering?: boolean;
}) {
const [hovered, setHovered] = useState(false);
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
@@ -158,7 +164,7 @@ function CompCard({
onClick={onSelect}
onPointerEnter={handleEnter}
onPointerLeave={handleLeave}
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
className={`group/card w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
isActive
? "bg-studio-accent/10 border-l-2 border-studio-accent"
: "border-l-2 border-transparent hover:bg-neutral-800/50"
@@ -200,6 +206,38 @@ function CompCard({
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
</div>
{onRender && (
<button
type="button"
title={isRendering ? "Rendering..." : `Render ${name}`}
aria-label={isRendering ? "Rendering..." : `Render ${name}`}
disabled={isRendering}
onClick={(e) => {
e.stopPropagation();
onRender();
}}
className={`flex-shrink-0 p-1 rounded transition-colors ${
isRendering
? "text-neutral-600 cursor-not-allowed"
: "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
}`}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
)}
</div>
);
}
@@ -209,6 +247,8 @@ export const CompositionsTab = memo(function CompositionsTab({
compositions,
activeComposition,
onSelect,
onRenderComposition,
isRendering,
}: CompositionsTabProps) {
if (compositions.length === 0) {
return (
@@ -227,6 +267,8 @@ export const CompositionsTab = memo(function CompositionsTab({
comp={comp}
isActive={activeComposition === comp}
onSelect={() => onSelect(comp)}
onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
isRendering={isRendering}
/>
))}
</div>
@@ -43,6 +43,8 @@ interface LeftSidebarProps {
onDuplicateFile?: (path: string) => void;
onMoveFile?: (oldPath: string, newPath: string) => void;
codeChildren?: ReactNode;
onRenderComposition?: (comp: string) => void;
isRendering?: boolean;
onLint?: () => void;
linting?: boolean;
onToggleCollapse?: () => void;
@@ -69,6 +71,8 @@ export const LeftSidebar = memo(
onDuplicateFile,
onMoveFile,
codeChildren,
onRenderComposition,
isRendering,
onLint,
linting,
onToggleCollapse,
@@ -169,6 +173,8 @@ export const LeftSidebar = memo(
compositions={compositions}
activeComposition={activeComposition}
onSelect={onSelectComposition}
onRenderComposition={onRenderComposition}
isRendering={isRendering}
/>
)}
{tab === "assets" && (