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
@@ -1,4 +1,4 @@
import type { RefObject } from "react";
import { useCallback, type RefObject } from "react";
import { SourceEditor } from "./editor/SourceEditor";
import { LeftSidebar, type LeftSidebarHandle } from "./sidebar/LeftSidebar";
import { MediaPreview } from "./MediaPreview";
@@ -6,6 +6,7 @@ import { isMediaFile } from "../utils/mediaTypes";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useStudioContext } from "../contexts/StudioContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
import { getPersistedRenderSettings } from "./renders/renderSettings";
export interface StudioLeftSidebarProps {
leftSidebarRef: RefObject<LeftSidebarHandle | null>;
@@ -28,7 +29,7 @@ export function StudioLeftSidebar({
handlePanelResizeMove,
handlePanelResizeEnd,
} = usePanelLayoutContext();
const { projectId } = useStudioContext();
const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioContext();
const {
compositions,
assets,
@@ -45,6 +46,15 @@ export function StudioLeftSidebar({
handleContentChange,
} = useFileManagerContext();
const handleRenderComposition = useCallback(
async (comp: string) => {
await waitForPendingDomEditSaves();
const { format, quality, fps } = getPersistedRenderSettings();
await renderQueue.startRender({ composition: comp, format, quality, fps });
},
[renderQueue, waitForPendingDomEditSaves],
);
if (leftCollapsed) {
return (
<div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
@@ -107,6 +117,8 @@ export function StudioLeftSidebar({
)
) : undefined
}
onRenderComposition={handleRenderComposition}
isRendering={renderQueue.isRendering}
onLint={onLint}
linting={linting}
onToggleCollapse={toggleLeftSidebar}