mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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" && (
|
||||
|
||||
Reference in New Issue
Block a user