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
View File
@@ -263,6 +263,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
format: opts.format,
outputResolution: opts.outputResolution,
...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}),
...(opts.composition ? { entryFile: opts.composition } : {}),
});
const startTime = Date.now();
const onProgress = (j: { progress: number; currentStage?: string }) => {
@@ -117,6 +117,83 @@ describe("POST /projects/:id/render — outputResolution forwarding", () => {
});
});
describe("POST /projects/:id/render — composition forwarding", () => {
it("forwards a valid composition path to the adapter", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
composition: "compositions/intro.html",
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].composition).toBe("compositions/intro.html");
} finally {
cleanup();
}
});
it("omits composition when not specified", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].composition).toBeUndefined();
} finally {
cleanup();
}
});
it("omits composition when empty string", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition: "" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].composition).toBeUndefined();
} finally {
cleanup();
}
});
it("rejects path-traversal attempts with 400", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
composition: "../../../etc/passwd",
}),
});
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — fps wire format", () => {
// The fps fraction-syntax feature accepts JSON `number` (integer fps) and
// JSON `string` (ffmpeg-style rational) on the wire, normalizing both to
+11 -1
View File
@@ -1,7 +1,7 @@
import type { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { join, resolve, sep } from "node:path";
import type { StudioApiAdapter, RenderJobState } from "../types.js";
import { VALID_CANVAS_RESOLUTIONS, parseFps, type CanvasResolution } from "../../core.types.js";
@@ -59,6 +59,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
quality?: string;
format?: string;
resolution?: string;
composition?: string;
};
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
@@ -76,6 +77,14 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "")
? (body.resolution as CanvasResolution)
: undefined;
let composition: string | undefined;
if (typeof body.composition === "string" && body.composition.length > 0) {
const resolved = resolve(project.dir, body.composition);
if (!resolved.startsWith(resolve(project.dir) + sep)) {
return c.json({ error: "composition path must be within the project directory" }, 400);
}
composition = body.composition;
}
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
@@ -94,6 +103,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
quality,
jobId,
outputResolution,
composition,
});
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });
+2
View File
@@ -88,6 +88,8 @@ export interface StudioApiAdapter {
* the producer for the integer-scale + aspect + HDR constraints.
*/
outputResolution?: CanvasResolution;
/** Entry file relative to projectDir (e.g. "compositions/intro.html"). Defaults to index.html. */
composition?: string;
}): RenderJobState;
/** Optional: generate a JPEG thumbnail via Puppeteer or similar. */
@@ -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}
@@ -198,7 +198,17 @@ export function StudioRightPanel({
onClearCompleted={renderQueue.clearCompleted}
onStartRender={async (format, quality, resolution, fps) => {
await waitForPendingDomEditSaves();
await renderQueue.startRender({ fps, quality, format, resolution });
const composition =
activeCompPath && activeCompPath !== "index.html"
? activeCompPath
: undefined;
await renderQueue.startRender({
fps,
quality,
format,
resolution,
composition,
});
}}
compositionDimensions={compositionDimensions}
isRendering={renderQueue.isRendering}
@@ -1,6 +1,7 @@
import { memo, useState, useRef, useEffect } from "react";
import { RenderQueueItem } from "./RenderQueueItem";
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
export interface CompositionDimensions {
width: number;
@@ -198,10 +199,11 @@ function FormatExportButton({
isRendering: boolean;
compositionDimensions?: CompositionDimensions | null;
}) {
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
const persisted = getPersistedRenderSettings();
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
const [fps, setFps] = useState<24 | 30 | 60>(30);
const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
const showQuality = format !== "mov";
@@ -228,7 +230,11 @@ function FormatExportButton({
{showQuality && (
<select
value={quality}
onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
onChange={(e) => {
const v = e.target.value as "draft" | "standard" | "high";
setQuality(v);
persistRenderSettings(format, v, fps);
}}
disabled={isRendering}
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
@@ -242,7 +248,11 @@ function FormatExportButton({
)}
<select
value={fps}
onChange={(e) => setFps(Number(e.target.value) as 24 | 30 | 60)}
onChange={(e) => {
const v = Number(e.target.value) as 24 | 30 | 60;
setFps(v);
persistRenderSettings(format, quality, v);
}}
disabled={isRendering}
title="Frames per second"
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
@@ -253,7 +263,11 @@ function FormatExportButton({
</select>
<select
value={format}
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
onChange={(e) => {
const v = e.target.value as "mp4" | "webm" | "mov";
setFormat(v);
persistRenderSettings(v, quality, fps);
}}
disabled={isRendering}
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
>
@@ -0,0 +1,38 @@
const RENDER_SETTINGS_KEY = "hf-studio-render-settings";
export interface PersistedRenderSettings {
format: "mp4" | "webm" | "mov";
quality: "draft" | "standard" | "high";
fps: 24 | 30 | 60;
}
export function getPersistedRenderSettings(): PersistedRenderSettings {
try {
const raw = localStorage.getItem(RENDER_SETTINGS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
format: ["mp4", "webm", "mov"].includes(parsed.format) ? parsed.format : "mp4",
quality: ["draft", "standard", "high"].includes(parsed.quality)
? parsed.quality
: "standard",
fps: [24, 30, 60].includes(parsed.fps) ? parsed.fps : 30,
};
}
} catch {
/* ignore */
}
return { format: "mp4", quality: "standard", fps: 30 };
}
export function persistRenderSettings(
format: PersistedRenderSettings["format"],
quality: PersistedRenderSettings["quality"],
fps: PersistedRenderSettings["fps"],
): void {
try {
localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify({ format, quality, fps }));
} catch {
/* ignore */
}
}
@@ -29,6 +29,8 @@ export interface StartRenderOptions {
format?: "mp4" | "webm" | "mov";
/** `"auto"` (default) renders at the composition's authored dimensions. */
resolution?: ResolutionPreset | "auto";
/** Render a specific composition file instead of index.html. */
composition?: string;
}
export function useRenderQueue(projectId: string | null) {
@@ -86,17 +88,25 @@ export function useRenderQueue(projectId: string | null) {
const quality = opts.quality ?? "standard";
const format = opts.format ?? "mp4";
const resolution = opts.resolution;
const composition = opts.composition;
const startTime = Date.now();
// "auto" / undefined means "render at the composition's authored size".
// Omit the field entirely — sending "auto" would trip the route's
// enum validation set.
const body: { fps: number; quality: string; format: string; resolution?: string } = {
const body: {
fps: number;
quality: string;
format: string;
resolution?: string;
composition?: string;
} = {
fps,
quality,
format,
};
if (resolution && resolution !== "auto") body.resolution = resolution;
if (composition) body.composition = composition;
let res: Response;
try {
res = await fetch(`/api/projects/${projectId}/render`, {
@@ -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" && (
+1
View File
@@ -201,6 +201,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
format: opts.format,
...(renderBodyScripts.length > 0 ? { renderBodyScripts } : {}),
outputResolution: opts.outputResolution,
...(opts.composition ? { entryFile: opts.composition } : {}),
});
const onProgress = (j: { progress: number; currentStage?: string }) => {
state.progress = j.progress;